From 5824500a287349a2ba09a54460d528783255d0b3 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 15:01:43 +0800 Subject: [PATCH 01/60] feat: update autodev icons with new ai-copilot design --- .../resources/icons/autodev-toolwindow.svg | 19 ++++++++++++++----- mpp-idea/src/main/resources/icons/autodev.svg | 19 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) 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 From e33d94fa5af38afd9f8e54aee341aa68ef84a130 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 07:21:45 +0000 Subject: [PATCH 02/60] fix(mpp-idea): replace Material Icons with local IdeaComposeIcons Fix NoClassDefFoundError for androidx/compose/material/icons/Icons in IntelliJ IDEA Compose environment. The mpp-ui module uses Material Icons which are not available in IntelliJ's Jewel UI environment. This fix creates a local icon provider (IdeaComposeIcons) that defines icons using ImageVector paths, avoiding the dependency on Material Icons. Icons provided: - Settings (gear/cog) - Build (wrench) - Error (circle with exclamation) - CheckCircle (circle with checkmark) --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 9 +- .../idea/toolwindow/IdeaComposeIcons.kt | 185 ++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt 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..95be90ef12 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 @@ -24,7 +24,6 @@ import cc.unitmesh.devins.idea.editor.IdeaInputTrigger import cc.unitmesh.devins.idea.renderer.JewelRenderer 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 com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx @@ -297,7 +296,7 @@ private fun ToolCallBubble(item: JewelRenderer.TimelineItem.ToolCallItem) { style = JewelTheme.defaultTextStyle.copy(color = statusColor) ) Icon( - imageVector = AutoDevComposeIcons.Build, + imageVector = IdeaComposeIcons.Build, contentDescription = "Tool", modifier = Modifier.size(14.dp), tint = JewelTheme.globalColors.text.normal @@ -349,7 +348,7 @@ private fun ErrorBubble(message: String) { verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = AutoDevComposeIcons.Error, + imageVector = IdeaComposeIcons.Error, contentDescription = "Error", modifier = Modifier.size(16.dp), tint = AutoDevColors.Red.c400 @@ -387,7 +386,7 @@ private fun TaskCompleteBubble(item: JewelRenderer.TimelineItem.TaskCompleteItem verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = if (item.success) AutoDevComposeIcons.CheckCircle else AutoDevComposeIcons.Error, + 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 @@ -463,7 +462,7 @@ private fun AgentTabsHeader( } IconButton(onClick = onSettings) { Icon( - imageVector = AutoDevComposeIcons.Settings, + imageVector = IdeaComposeIcons.Settings, contentDescription = "Settings", modifier = Modifier.size(16.dp), tint = JewelTheme.globalColors.text.normal 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..1de3d699d7 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -0,0 +1,185 @@ +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() + } +} + From c30107ed61d18bae3e1194a881b2ecf7ca5d08b2 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 15:36:19 +0800 Subject: [PATCH 03/60] fix(editor): wrap document and caret actions in runReadAction Ensure document listener addition and caret movement are executed within a read action to improve thread safety. --- .../cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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..1e69fc6a61 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 @@ -12,6 +12,7 @@ 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 @@ -123,10 +124,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 +160,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 From c01f9dda4dda50b94009cee62bcd1086e28baddc Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 08:01:55 +0000 Subject: [PATCH 04/60] refactor(mpp-idea): reuse CodeReviewViewModel from mpp-ui - Simplify IdeaCodeReviewViewModel by extending CodeReviewViewModel from mpp-ui - Remove duplicate IdeaCodeReviewModels.kt, use shared models from mpp-ui - Update IdeaCodeReviewContent.kt to use shared model types - Fix dependency configuration in build.gradle.kts and settings.gradle.kts - All CodeReview features now inherited from base class: - Plan generation (generateModificationPlan) - Fix generation (generateFixes, proceedToGenerateFixes) - Lint analysis (runLint, analyzeModifiedCode) - Issue tracking (loadIssueForCommit) - Test discovery (findRelatedTests) - File viewer support (openFile, closeFileViewer) --- mpp-idea/build.gradle.kts | 7 +- mpp-idea/settings.gradle.kts | 14 +- .../codereview/IdeaCodeReviewContent.kt | 39 +- .../codereview/IdeaCodeReviewModels.kt | 71 ---- .../codereview/IdeaCodeReviewViewModel.kt | 350 ++---------------- 5 files changed, 70 insertions(+), 411 deletions(-) delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewModels.kt diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index 9e808eed86..288ba004d2 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -25,6 +25,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 +42,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") @@ -78,7 +81,7 @@ dependencies { // 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") diff --git a/mpp-idea/settings.gradle.kts b/mpp-idea/settings.gradle.kts index a205d98f32..c8a23317c4 100644 --- a/mpp-idea/settings.gradle.kts +++ b/mpp-idea/settings.gradle.kts @@ -18,11 +18,19 @@ pluginManagement { } // 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" 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") } } 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..44537f732e 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 @@ -16,6 +16,10 @@ 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.agent.codereview.AIAnalysisProgress +import cc.unitmesh.devins.ui.compose.agent.codereview.AnalysisStage +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 @@ -36,7 +40,7 @@ fun IdeaCodeReviewContent(viewModel: IdeaCodeReviewViewModel) { selectedIndices = state.selectedCommitIndices, isLoading = state.isLoading, onCommitSelect = { index -> - viewModel.selectCommits(setOf(index)) + viewModel.selectCommit(index) }, modifier = Modifier.width(280.dp).fillMaxHeight() ) @@ -67,7 +71,7 @@ fun IdeaCodeReviewContent(viewModel: IdeaCodeReviewViewModel) { @Composable private fun CommitListPanel( - commits: List, + commits: List, selectedIndices: Set, isLoading: Boolean, onCommitSelect: (Int) -> Unit, @@ -122,7 +126,7 @@ private fun CommitListPanel( @Composable private fun CommitItem( - commit: IdeaCommitInfo, + commit: CommitInfo, isSelected: Boolean, onClick: () -> Unit ) { @@ -172,7 +176,7 @@ private fun CommitItem( @Composable private fun DiffViewerPanel( - diffFiles: List, + diffFiles: List, selectedFileIndex: Int, isLoading: Boolean, onFileSelect: (Int) -> Unit, @@ -251,7 +255,7 @@ private fun DiffViewerPanel( } @Composable -private fun DiffContent(file: IdeaDiffFileInfo) { +private fun DiffContent(file: DiffFileInfo) { val scrollState = rememberScrollState() Column( @@ -311,7 +315,7 @@ private fun DiffContent(file: IdeaDiffFileInfo) { @Composable private fun AIAnalysisPanel( - progress: IdeaAIAnalysisProgress, + progress: AIAnalysisProgress, error: String?, onStartAnalysis: () -> Unit, onCancelAnalysis: () -> Unit, @@ -333,7 +337,7 @@ private fun AIAnalysisPanel( ) when (progress.stage) { - IdeaAnalysisStage.IDLE, IdeaAnalysisStage.COMPLETED, IdeaAnalysisStage.ERROR -> { + AnalysisStage.IDLE, AnalysisStage.COMPLETED, AnalysisStage.ERROR -> { DefaultButton(onClick = onStartAnalysis) { Text("Start Analysis") } @@ -355,18 +359,19 @@ private fun AIAnalysisPanel( 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 + AnalysisStage.IDLE -> "Ready" to JewelTheme.globalColors.text.info + AnalysisStage.RUNNING_LINT -> "Running lint..." to AutoDevColors.Amber.c400 + AnalysisStage.ANALYZING_LINT -> "Analyzing code..." to AutoDevColors.Blue.c400 + AnalysisStage.GENERATING_PLAN -> "Generating plan..." to AutoDevColors.Blue.c400 + AnalysisStage.WAITING_FOR_USER_INPUT -> "Waiting for input..." to AutoDevColors.Amber.c400 + AnalysisStage.GENERATING_FIX -> "Generating fixes..." to AutoDevColors.Blue.c400 + AnalysisStage.COMPLETED -> "Completed" to AutoDevColors.Green.c400 + AnalysisStage.ERROR -> "Error" to AutoDevColors.Red.c400 } - if (progress.stage != IdeaAnalysisStage.IDLE && - progress.stage != IdeaAnalysisStage.COMPLETED && - progress.stage != IdeaAnalysisStage.ERROR) { + if (progress.stage != AnalysisStage.IDLE && + progress.stage != AnalysisStage.COMPLETED && + progress.stage != AnalysisStage.ERROR) { CircularProgressIndicator() } 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..e4880a2afc 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,59 @@ 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}") } - } - } - } - } - - /** - * Load recent git commits - */ - suspend fun loadCommitHistory(count: Int = 50) { - updateState { it.copy(isLoading = true, error = null) } - - try { - val totalCount = gitOps.getTotalCommitCount() - val gitCommits = gitOps.getRecentCommits(count) - - 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 - ) - } - - if (commits.isNotEmpty()) { - loadCommitDiff(setOf(0)) - } - } catch (e: Exception) { - updateState { - it.copy(isLoading = false, error = "Failed to load commits: ${e.message}") - } - } - } +) : CodeReviewViewModel( + workspace = createWorkspaceFromProject(project) +), Disposable { - /** - * Select commits and load their diff - */ - fun selectCommits(indices: Set) { - coroutineScope.launch { - loadCommitDiff(indices) - } - } + private val logger = Logger.getInstance(IdeaCodeReviewViewModel::class.java) - /** - * 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 - ) - } - return - } - - updateState { - it.copy(isLoadingDiff = true, selectedCommitIndices = selectedIndices, error = null) - } + // JewelRenderer for IntelliJ native theme + val jewelRenderer = JewelRenderer() - try { - val sortedIndices = selectedIndices.sorted() - val newestIndex = sortedIndices.first() - val oldestIndex = sortedIndices.last() + companion object { + /** + * Create a Workspace from an IntelliJ Project + */ + private fun createWorkspaceFromProject(project: Project): Workspace { + val projectPath = project.basePath + val projectName = project.name - val currentState = _state.value - val newestCommit = currentState.commitHistory[newestIndex] - val oldestCommit = currentState.commitHistory[oldestIndex] - - val gitDiff = if (newestIndex == oldestIndex) { - gitOps.getCommitDiff(newestCommit.hash) + return if (projectPath != null) { + DefaultWorkspace.create(projectName, projectPath) } 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") } - 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}" - ) - } + DefaultWorkspace.createEmpty(projectName) } } } /** - * Cancel current analysis - */ - fun cancelAnalysis() { - currentJob?.cancel() - updateState { it.copy(aiProgress = IdeaAIAnalysisProgress(stage = IdeaAnalysisStage.IDLE)) } - } - - /** - * Initialize the CodeReviewAgent + * Dispose resources when the ViewModel is no longer needed */ - 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 } } - From dde5b04d3514d0779a7068a2bde3ccf17d23e32f Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 08:21:55 +0000 Subject: [PATCH 05/60] feat(mpp-idea): implement Knowledge Agent functionality Add Knowledge Agent feature to mpp-idea, aligned with mpp-ui's DocumentReaderViewModel. New files: - IdeaKnowledgeModels.kt: State and data models for Knowledge Agent - IdeaKnowledgeViewModel.kt: ViewModel with DocumentAgent integration - IdeaKnowledgeContent.kt: Three-panel UI (document list, viewer, AI chat) Features: - Project document scanning (md, pdf, docx, kt, java, py, etc.) - Document content viewing with line numbers and TOC navigation - AI-powered document querying via DocumentAgent - LLM configuration via shared ConfigManager Modified files: - IdeaAgentApp.kt: Integrate Knowledge tab with IdeaKnowledgeContent - IdeaComposeIcons.kt: Add Refresh, Description, Code, Delete icons Reuses mpp-core components: - DocumentAgent, DocumentRegistry, DocumentParserFactory - ConfigManager for LLM configuration - JewelRenderer for agent output rendering --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 24 +- .../idea/toolwindow/IdeaComposeIcons.kt | 148 ++++ .../knowledge/IdeaKnowledgeContent.kt | 758 ++++++++++++++++++ .../knowledge/IdeaKnowledgeModels.kt | 120 +++ .../knowledge/IdeaKnowledgeViewModel.kt | 390 +++++++++ 5 files changed, 1434 insertions(+), 6 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeModels.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeViewModel.kt 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 95be90ef12..00ed6f3fba 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 @@ -24,6 +24,8 @@ import cc.unitmesh.devins.idea.editor.IdeaInputTrigger import cc.unitmesh.devins.idea.renderer.JewelRenderer import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel +import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeContent +import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.Disposable import com.intellij.openapi.editor.ex.EditorEx @@ -68,6 +70,9 @@ fun IdeaAgentApp( // 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) } + // Auto-scroll to bottom when new items arrive LaunchedEffect(timeline.size, streamingOutput) { if (timeline.isNotEmpty() || streamingOutput.isNotEmpty()) { @@ -83,15 +88,22 @@ fun IdeaAgentApp( if (currentAgentType == AgentType.CODE_REVIEW && codeReviewViewModel == null) { codeReviewViewModel = IdeaCodeReviewViewModel(project, coroutineScope) } + if (currentAgentType == AgentType.KNOWLEDGE && knowledgeViewModel == null) { + knowledgeViewModel = IdeaKnowledgeViewModel(project, coroutineScope) + } } - // 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 + } } } @@ -130,7 +142,9 @@ fun IdeaAgentApp( } ?: EmptyStateMessage("Loading Code Review...") } AgentType.KNOWLEDGE -> { - KnowledgeContent() + knowledgeViewModel?.let { vm -> + IdeaKnowledgeContent(viewModel = vm) + } ?: EmptyStateMessage("Loading Knowledge Agent...") } } } @@ -215,10 +229,8 @@ private fun TimelineItemView(item: JewelRenderer.TimelineItem) { } } -@Composable -private fun KnowledgeContent() { - EmptyStateMessage("Knowledge mode - Coming soon!") -} +// KnowledgeContent is now implemented in IdeaKnowledgeContent.kt +// See IdeaAgentApp main content switch for integration @Composable private fun EmptyStateMessage(text: String) { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 1de3d699d7..f3eb502b41 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -181,5 +181,153 @@ object IdeaComposeIcons { } }.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() + } } 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..70f760a5b5 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -0,0 +1,758 @@ +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 cc.unitmesh.devins.idea.renderer.JewelRenderer +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.* + +/** + * Main content view for Knowledge Agent in IntelliJ IDEA. + * Provides document browsing, search, and AI-powered document querying. + * + * Layout: + * - Left: Document list with search + * - Center: Document content viewer + * - Right: AI Chat interface + */ +@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() + + Row( + modifier = modifier.fillMaxSize() + ) { + // 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.width(250.dp) + ) + + Divider(Orientation.Vertical, modifier = Modifier.fillMaxHeight().width(1.dp)) + + // Center panel: Document content viewer + DocumentContentPanel( + document = state.selectedDocument, + content = state.documentContent ?: state.parsedContent, + isLoading = state.isLoading, + targetLineNumber = state.targetLineNumber, + highlightedText = state.highlightedText, + onTocItemClick = { viewModel.navigateToTocItem(it) }, + modifier = Modifier.weight(1f) + ) + + Divider(Orientation.Vertical, modifier = Modifier.fillMaxHeight().width(1.dp)) + + // 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.width(400.dp) + ) + } +} + +/** + * 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 +) { + 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( + value = searchQuery, + onValueChange = onSearchQueryChange, + 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) { + JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) + } 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 + */ +@Composable +private fun DocumentContentPanel( + document: IdeaDocumentFile?, + content: String?, + isLoading: Boolean, + targetLineNumber: Int?, + highlightedText: String?, + onTocItemClick: (cc.unitmesh.devins.document.TOCItem) -> Unit, + 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)) + + // TOC panel if available + if (document.toc.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text( + text = "Table of Contents (${document.toc.size})", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + Spacer(modifier = Modifier.height(4.dp)) + LazyColumn( + modifier = Modifier.heightIn(max = 150.dp) + ) { + items(document.toc) { tocItem -> + Text( + text = "${" ".repeat(tocItem.level - 1)}• ${tocItem.title}", + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + modifier = Modifier + .fillMaxWidth() + .clickable { onTocItemClick(tocItem) } + .padding(vertical = 2.dp) + ) + } + } + } + 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.Yellow.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 +) { + var inputText by remember { mutableStateOf("") } + val listState = rememberLazyListState() + + // 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( + value = inputText, + onValueChange = { inputText = it }, + 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) + inputText = "" + } + }, + enabled = inputText.isNotBlank() + ) { + Text("Send") + } + } + } + } +} + +/** + * Chat message item renderer + */ +@Composable +private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { + when (item) { + is JewelRenderer.TimelineItem.MessageItem -> { + val isUser = item.role == JewelRenderer.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 JewelRenderer.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 + ) + ) + } + if (item.output != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = item.output.take(200) + if (item.output.length > 200) "..." else "", + style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) + ) + } + } + } + } + } + + is JewelRenderer.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 JewelRenderer.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 JewelRenderer.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 + ) + ) + } + } + } + } +} + +/** + * 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..f31e379634 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeModels.kt @@ -0,0 +1,120 @@ +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 +} + +/** + * Document index record for tracking indexed documents. + */ +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..9f72a3a218 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeViewModel.kt @@ -0,0 +1,390 @@ +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 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 { + coroutineScope.launch { + try { + initializeLLMService() + loadDocuments() + } 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) { + coroutineScope.launch { + loadDocumentContent(document) + } + } + + /** + * Load document content + */ + private suspend fun loadDocumentContent(document: IdeaDocumentFile) { + updateState { it.copy(isLoading = true, error = null) } + + try { + val file = 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 + ) + ) + + val (content, parsedDoc) = 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() + renderer.renderError("Generation cancelled by user") + } 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) + } + } + } + + /** + * Clear navigation state + */ + fun clearNavigation() { + updateState { + it.copy( + targetLineNumber = null, + highlightedText = null + ) + } + } + + /** + * Refresh documents list + */ + fun refreshDocuments() { + coroutineScope.launch { + loadDocuments() + } + } + + private fun updateState(update: (IdeaKnowledgeState) -> IdeaKnowledgeState) { + _state.value = update(_state.value) + } + + override fun dispose() { + currentJob?.cancel() + } +} + From 19bd6761c78a5b82dc1ded1e9d905655edd2f203 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 08:28:54 +0000 Subject: [PATCH 06/60] fix(mpp-idea): address PR review comments for Knowledge Agent Fixes based on review feedback: 1. EDT violations - Move IO-heavy operations to Dispatchers.IO: - initializeLLMService() and loadDocuments() in init block - selectDocument() file loading - File read/parse operations in loadDocumentContent() - refreshDocuments() document scanning 2. Init coroutine tracking - Store initJob for proper disposal: - Add initJob property to track init coroutine - Cancel initJob in dispose() - Handle CancellationException in init block 3. Cancellation handling - Remove error message for intentional cancellation: - Change renderError() to comment for CancellationException in sendMessage() 4. Selection visual distinction - Improve selected item highlighting: - Use AutoDevColors.Blue.c400.copy(alpha = 0.15f) for better visibility 5. Unused code - Comment out IdeaDocumentIndexRecord: - Add TODO comment for future indexing feature --- .../knowledge/IdeaKnowledgeContent.kt | 2 +- .../knowledge/IdeaKnowledgeModels.kt | 26 +++++++------ .../knowledge/IdeaKnowledgeViewModel.kt | 38 ++++++++++++------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index 70f760a5b5..f29a4e35e3 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -193,7 +193,7 @@ private fun DocumentListItem( onClick: () -> Unit ) { val backgroundColor = if (isSelected) { - JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) + AutoDevColors.Blue.c400.copy(alpha = 0.15f) } else { JewelTheme.globalColors.panelBackground } 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 index f31e379634..d327958be1 100644 --- 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 @@ -105,16 +105,18 @@ enum class IndexingStatus { ERROR } -/** - * Document index record for tracking indexed documents. - */ -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 -) +// 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 index 9f72a3a218..5b6af795a4 100644 --- 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 @@ -38,6 +38,7 @@ class IdeaKnowledgeViewModel( 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 @@ -50,10 +51,13 @@ class IdeaKnowledgeViewModel( if (projectPath.isEmpty()) { updateState { it.copy(error = "No project path available") } } else { - coroutineScope.launch { + // 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}") } } @@ -183,19 +187,20 @@ class IdeaKnowledgeViewModel( * Select a document */ fun selectDocument(document: IdeaDocumentFile) { - coroutineScope.launch { + // Launch on IO dispatcher to avoid EDT violations + coroutineScope.launch(Dispatchers.IO) { loadDocumentContent(document) } } /** - * Load document content + * Load document content - runs on IO dispatcher */ private suspend fun loadDocumentContent(document: IdeaDocumentFile) { updateState { it.copy(isLoading = true, error = null) } try { - val file = File(projectPath, document.path) + val file = withContext(Dispatchers.IO) { File(projectPath, document.path) } if (!file.exists()) { updateState { it.copy(isLoading = false, error = "File not found: ${document.path}") @@ -224,14 +229,17 @@ class IdeaKnowledgeViewModel( ) ) - val (content, parsedDoc) = 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 + // 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 @@ -297,7 +305,7 @@ class IdeaKnowledgeViewModel( agent.execute(task) { _ -> } } catch (e: CancellationException) { renderer.forceStop() - renderer.renderError("Generation cancelled by user") + // Intentional cancellation - no error message needed } catch (e: Exception) { renderer.renderError("Error: ${e.message}") } finally { @@ -374,7 +382,8 @@ class IdeaKnowledgeViewModel( * Refresh documents list */ fun refreshDocuments() { - coroutineScope.launch { + // Launch on IO dispatcher to avoid EDT violations + coroutineScope.launch(Dispatchers.IO) { loadDocuments() } } @@ -384,6 +393,7 @@ class IdeaKnowledgeViewModel( } override fun dispose() { + initJob?.cancel() currentJob?.cancel() } } From 1624e6b9bac02b5f86aa1a2d104fd97a61a967a3 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 08:33:45 +0000 Subject: [PATCH 07/60] fix(mpp-idea): guard against invalid TOC levels in IdeaKnowledgeContent Use coerceAtLeast(1) to prevent IllegalArgumentException when tocItem.level is zero or negative in repeat() call. --- .../devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index f29a4e35e3..8c157765b2 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -340,8 +340,10 @@ private fun DocumentContentPanel( modifier = Modifier.heightIn(max = 150.dp) ) { items(document.toc) { tocItem -> + // Guard against zero or negative levels to prevent IllegalArgumentException + val safeLevel = tocItem.level.coerceAtLeast(1) Text( - text = "${" ".repeat(tocItem.level - 1)}• ${tocItem.title}", + text = "${" ".repeat(safeLevel - 1)}• ${tocItem.title}", style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), modifier = Modifier .fillMaxWidth() From 562466059aac957133eefeb818ad534b5850a84a Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 16:46:57 +0800 Subject: [PATCH 08/60] feat(ui): use TextField state for search and input sync Replace string-based TextField handling with state objects to better synchronize UI input and external changes in search and chat components. --- .../knowledge/IdeaKnowledgeContent.kt | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index 8c157765b2..4a4b3333ba 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -12,9 +12,12 @@ 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.idea.renderer.JewelRenderer import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons 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.* @@ -94,6 +97,22 @@ private fun DocumentListPanel( 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() @@ -127,8 +146,7 @@ private fun DocumentListPanel( // Search input TextField( - value = searchQuery, - onValueChange = onSearchQueryChange, + state = searchTextFieldState, placeholder = { Text("Search documents...") }, modifier = Modifier.fillMaxWidth() ) @@ -386,7 +404,7 @@ private fun DocumentContentPanel( modifier = Modifier .fillMaxWidth() .background( - if (isHighlighted) AutoDevColors.Yellow.c400.copy(alpha = 0.2f) + if (isHighlighted) AutoDevColors.Amber.c400.copy(alpha = 0.2f) else JewelTheme.globalColors.panelBackground ) ) { @@ -438,9 +456,17 @@ private fun AIChatPanel( 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()) { @@ -550,8 +576,7 @@ private fun AIChatPanel( verticalAlignment = Alignment.CenterVertically ) { TextField( - value = inputText, - onValueChange = { inputText = it }, + state = inputTextFieldState, placeholder = { Text("Ask about your documents...") }, modifier = Modifier.weight(1f), enabled = !isGenerating @@ -566,7 +591,7 @@ private fun AIChatPanel( onClick = { if (inputText.isNotBlank()) { onSendMessage(inputText) - inputText = "" + inputTextFieldState.edit { replace(0, length, "") } } }, enabled = inputText.isNotBlank() From 507a6bd5c08bcd6d12815a6f265f5414a4caa580 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 16:51:14 +0800 Subject: [PATCH 09/60] chore(build): remove SQLDelight exclusion from dependencies No longer exclude SQLDelight from IntelliJ plugin dependencies in build script. --- mpp-idea/build.gradle.kts | 5 +++-- .../cc/unitmesh/devins/db/DatabaseDriverFactory.kt | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index 288ba004d2..c6e5e0563c 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -78,8 +78,6 @@ 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:mpp-core:$mppVersion") { // Exclude Compose dependencies from mpp-core as well @@ -107,6 +105,9 @@ dependencies { compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") + // SQLite JDBC driver for SQLDelight (required at runtime) + implementation("org.xerial:sqlite-jdbc:3.49.1.0") + // 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") 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}", From aa5566fe4b7ac32104f7e2d76cdd78927c830f2e Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 17:18:00 +0800 Subject: [PATCH 10/60] refactor(mpp-idea): extract IdeaAgentApp components into separate files - Extract timeline components: IdeaTimelineContent, IdeaMessageBubble, IdeaToolCallBubble, IdeaErrorBubble, IdeaTaskCompleteBubble, IdeaTerminalOutputBubble - Extract header component: IdeaAgentTabsHeader - Extract status component: IdeaToolLoadingStatusBar - Reduce IdeaAgentApp.kt from 780 lines to 276 lines - Align component structure with mpp-ui patterns --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 528 +----------------- .../toolwindow/header/IdeaAgentTabsHeader.kt | 107 ++++ .../status/IdeaToolLoadingStatusBar.kt | 125 +++++ .../toolwindow/timeline/IdeaErrorBubble.kt | 54 ++ .../toolwindow/timeline/IdeaMessageBubble.kt | 72 +++ .../timeline/IdeaTaskCompleteBubble.kt | 60 ++ .../timeline/IdeaTerminalOutputBubble.kt | 80 +++ .../timeline/IdeaTimelineContent.kt | 95 ++++ .../toolwindow/timeline/IdeaToolCallBubble.kt | 93 +++ 9 files changed, 698 insertions(+), 516 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaErrorBubble.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaMessageBubble.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt 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 00ed6f3fba..c62b6058f0 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,41 +2,32 @@ 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.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel +import cc.unitmesh.devins.idea.toolwindow.header.IdeaAgentTabsHeader import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeContent import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel -import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devins.idea.toolwindow.status.IdeaToolLoadingStatusBar +import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage +import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent 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 @@ -113,7 +104,7 @@ fun IdeaAgentApp( .background(JewelTheme.globalColors.panelBackground) ) { // Agent Type Tabs Header - AgentTabsHeader( + IdeaAgentTabsHeader( currentAgentType = currentAgentType, onAgentTypeChange = { viewModel.onAgentTypeChange(it) }, onNewChat = { viewModel.clearHistory() }, @@ -130,7 +121,7 @@ fun IdeaAgentApp( ) { when (currentAgentType) { AgentType.CODING, AgentType.REMOTE, AgentType.LOCAL_CHAT -> { - TimelineContent( + IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, listState = listState @@ -139,12 +130,12 @@ fun IdeaAgentApp( AgentType.CODE_REVIEW -> { codeReviewViewModel?.let { vm -> IdeaCodeReviewContent(viewModel = vm) - } ?: EmptyStateMessage("Loading Code Review...") + } ?: IdeaEmptyStateMessage("Loading Code Review...") } AgentType.KNOWLEDGE -> { knowledgeViewModel?.let { vm -> IdeaKnowledgeContent(viewModel = vm) - } ?: EmptyStateMessage("Loading Knowledge Agent...") + } ?: IdeaEmptyStateMessage("Loading Knowledge Agent...") } } } @@ -153,7 +144,7 @@ fun IdeaAgentApp( // Input area (only for chat-based modes) if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.REMOTE || currentAgentType == AgentType.LOCAL_CHAT) { - DevInInputArea( + IdeaDevInInputArea( project = project, parentDisposable = viewModel, isProcessing = isExecuting, @@ -169,508 +160,13 @@ fun IdeaAgentApp( } // 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) - } - } -} - -// KnowledgeContent is now implemented in IdeaKnowledgeContent.kt -// See IdeaAgentApp main content switch for integration - -@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 = IdeaComposeIcons.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 = IdeaComposeIcons.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) 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 - ) - ) - } - } - } -} - -@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 = IdeaComposeIcons.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 - ) - ) - } - } - } -} - -@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 - ) - ) - } -} - /** * Advanced chat input area with full DevIn language support. * @@ -684,7 +180,7 @@ private fun ToolStatusChip( * - Stop/Send button based on execution state */ @Composable -private fun DevInInputArea( +private fun IdeaDevInInputArea( project: Project, parentDisposable: Disposable, isProcessing: Boolean, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt new file mode 100644 index 0000000000..fd253466a0 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt @@ -0,0 +1,107 @@ +package cc.unitmesh.devins.idea.toolwindow.header + +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.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 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.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.theme.defaultBannerStyle + +/** + * Agent tabs header for switching between agent types. + * Similar to AgentTopAppBar in mpp-ui but using Jewel theming. + */ +@Composable +fun IdeaAgentTabsHeader( + currentAgentType: AgentType, + onAgentTypeChange: (AgentType) -> Unit, + onNewChat: () -> Unit, + onSettings: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(36.dp) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left: Agent Type Tabs + 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 -> + IdeaAgentTab( + 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 = IdeaComposeIcons.Settings, + contentDescription = "Settings", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal + ) + } + } + } +} + +/** + * Individual agent tab button. + */ +@Composable +fun IdeaAgentTab( + type: AgentType, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + 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 + ) + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt new file mode 100644 index 0000000000..056ad0bbbf --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt @@ -0,0 +1,125 @@ +package cc.unitmesh.devins.idea.toolwindow.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/toolwindow/timeline/IdeaErrorBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaErrorBubble.kt new file mode 100644 index 0000000000..03c94e1e15 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaErrorBubble.kt @@ -0,0 +1,54 @@ +package cc.unitmesh.devins.idea.toolwindow.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/toolwindow/timeline/IdeaMessageBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaMessageBubble.kt new file mode 100644 index 0000000000..9ce4a771e5 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaMessageBubble.kt @@ -0,0 +1,72 @@ +package cc.unitmesh.devins.idea.toolwindow.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.idea.renderer.JewelRenderer +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: JewelRenderer.MessageRole, + content: String, + modifier: Modifier = Modifier +) { + 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 + ) + } + } +} + +/** + * 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/toolwindow/timeline/IdeaTaskCompleteBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt new file mode 100644 index 0000000000..8eff1ae2a6 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt @@ -0,0 +1,60 @@ +package cc.unitmesh.devins.idea.toolwindow.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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.renderer.JewelRenderer +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: JewelRenderer.TimelineItem.TaskCompleteItem, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .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) 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/toolwindow/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt new file mode 100644 index 0000000000..ec25d07604 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt @@ -0,0 +1,80 @@ +package cc.unitmesh.devins.idea.toolwindow.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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Terminal output bubble for displaying shell command results. + * Similar to TerminalOutputItem in mpp-ui but using Jewel theming. + */ +@Composable +fun IdeaTerminalOutputBubble( + item: JewelRenderer.TimelineItem.TerminalOutputItem, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .widthIn(max = 600.dp) + .background(AutoDevColors.Neutral.c900) + .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 + ) + ) + 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 + ) + ) + } + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt new file mode 100644 index 0000000000..4d9f9a4c1c --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt @@ -0,0 +1,95 @@ +package cc.unitmesh.devins.idea.toolwindow.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.devins.idea.renderer.JewelRenderer +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 +) { + 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) + } + + // Show streaming output + if (streamingOutput.isNotEmpty()) { + item { + IdeaStreamingMessageBubble(streamingOutput) + } + } + } + } +} + +/** + * Dispatch timeline item to appropriate bubble component. + */ +@Composable +fun IdeaTimelineItemView(item: JewelRenderer.TimelineItem) { + when (item) { + is JewelRenderer.TimelineItem.MessageItem -> { + IdeaMessageBubble( + role = item.role, + content = item.content + ) + } + is JewelRenderer.TimelineItem.ToolCallItem -> { + IdeaToolCallBubble(item) + } + is JewelRenderer.TimelineItem.ErrorItem -> { + IdeaErrorBubble(item.message) + } + is JewelRenderer.TimelineItem.TaskCompleteItem -> { + IdeaTaskCompleteBubble(item) + } + is JewelRenderer.TimelineItem.TerminalOutputItem -> { + IdeaTerminalOutputBubble(item) + } + } +} + +/** + * 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/toolwindow/timeline/IdeaToolCallBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt new file mode 100644 index 0000000000..39b9bfee25 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt @@ -0,0 +1,93 @@ +package cc.unitmesh.devins.idea.toolwindow.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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.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 + +/** + * Tool call bubble for displaying tool execution with status. + * Similar to ToolItem/CombinedToolItem in mpp-ui but using Jewel theming. + */ +@Composable +fun IdeaToolCallBubble( + item: JewelRenderer.TimelineItem.ToolCallItem, + modifier: Modifier = Modifier +) { + 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 status icon + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val statusIcon = when (item.success) { + true -> "+" + false -> "x" + null -> "..." + } + // Use AutoDevColors design system for consistency + 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) + ) + Icon( + imageVector = IdeaComposeIcons.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) + ) + } + } + } + } +} + From dfe45215c9770ac729037f8150a7c1f6e920fd26 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 17:22:28 +0800 Subject: [PATCH 11/60] feat(mpp-idea): enhance IdeaToolCallBubble with expand/collapse and copy - Add AnimatedVisibility for expand/collapse animation - Add copy to clipboard functionality for params and output - Add status icons (executing, success, failed) with colors - Add execution time display - Add 'Show Full' toggle for long params/output - Add IdeaCurrentToolCallItem for executing tool indicator - Add new icons: PlayArrow, ExpandLess, ExpandMore, ContentCopy Aligns with mpp-ui ToolItem/ToolResultItem patterns --- .../idea/toolwindow/IdeaComposeIcons.kt | 115 +++++ .../toolwindow/timeline/IdeaToolCallBubble.kt | 411 +++++++++++++++--- 2 files changed, 474 insertions(+), 52 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index f3eb502b41..e4cb1e619e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -329,5 +329,120 @@ object IdeaComposeIcons { } }.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() + } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt index 39b9bfee25..a02b5a0498 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt @@ -1,10 +1,19 @@ package cc.unitmesh.devins.idea.toolwindow.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.runtime.Composable +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 @@ -13,81 +22,379 @@ 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 +import java.awt.Toolkit +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: JewelRenderer.TimelineItem.ToolCallItem, modifier: Modifier = Modifier ) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start + // 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) ) { - Box( - modifier = Modifier - .widthIn(max = 500.dp) - .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) - .padding(8.dp) - ) { - Column { - // Tool name with status icon - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val statusIcon = when (item.success) { - true -> "+" - false -> "x" - null -> "..." + 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 } - // Use AutoDevColors design system for consistency - 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) - ) - Icon( - imageVector = IdeaComposeIcons.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()) { + // 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(200) + if (item.params.length > 200) "..." else "", + text = "-> ${item.params.take(40)}${if (item.params.length > 40) "..." else ""}", style = JewelTheme.defaultTextStyle.copy( fontSize = 12.sp, - color = JewelTheme.globalColors.text.info - ) + color = when { + item.success == true -> AutoDevColors.Green.c400 + item.success == false -> AutoDevColors.Red.c400 + else -> JewelTheme.globalColors.text.info + } + ), + maxLines = 1 ) } - // 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) + // Execution time (if available) + item.executionTimeMs?.let { time -> + if (time > 0) { + 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 (JVM implementation) + */ +private fun copyToClipboard(text: String) { + try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(text), null) + } catch (e: Exception) { + // Ignore clipboard errors + } +} + +/** + * Format tool output for better readability + */ +private fun formatToolOutput(output: String): String { + return when { + output.trim().startsWith("{") || output.trim().startsWith("[") -> { + try { + output.replace(",", ",\n") + .replace("{", "{\n ") + .replace("}", "\n}") + .replace("[", "[\n ") + .replace("]", "\n]") + } catch (e: Exception) { + output + } } + output.contains("|") -> output // Table format + output.contains("\n") -> output + output.length > 100 -> "${output.take(100)}..." + else -> output } } From fc681c1b1f06f3f75dc04fd1f11490ed89627189 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 17:41:10 +0800 Subject: [PATCH 12/60] feat(mpp-idea): add shell execution support to IdeaTerminalOutputBubble - Add IdeaShellExecutor service for command execution using PtyCommandLine - Add IdeaUIWriter for streaming output updates with state callbacks - Enhance IdeaTerminalOutputBubble with execute/stop, expand/collapse, copy - Add Terminal and Stop icons to IdeaComposeIcons - Pass project through timeline components for shell access - Add status badges (Ready, Running, Success, Failed, Terminated) - Add execution time display and 'Show Full Output' toggle --- .../devins/idea/services/IdeaShellExecutor.kt | 160 ++++++++ .../devins/idea/services/IdeaUIWriter.kt | 75 ++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 3 +- .../idea/toolwindow/IdeaComposeIcons.kt | 68 ++++ .../timeline/IdeaTerminalOutputBubble.kt | 365 ++++++++++++++++-- .../timeline/IdeaTimelineContent.kt | 11 +- 6 files changed, 640 insertions(+), 42 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt new file mode 100644 index 0000000000..1fff4eafeb --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt @@ -0,0 +1,160 @@ +package cc.unitmesh.devins.idea.services + +import com.intellij.execution.configurations.PtyCommandLine +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.util.text.Strings +import com.intellij.util.execution.ParametersListUtil +import com.intellij.util.io.awaitExit +import kotlinx.coroutines.* +import java.io.* + +/** + * Result from shell command execution. + */ +data class ShellExecutorResult( + val exitCode: Int, + val stdOutput: String, + val errOutput: String +) + +/** + * Execution state for terminal commands. + * Aligned with ext-terminal's TerminalExecutionState. + */ +enum class IdeaTerminalExecutionState { + READY, + EXECUTING, + SUCCESS, + FAILED, + TERMINATED +} + +/** + * Shell command executor service for mpp-idea. + * Based on ProcessExecutor from core module but adapted for standalone use. + */ +@Service(Service.Level.PROJECT) +class IdeaShellExecutor(private val project: Project) { + + /** + * Execute shell command with streaming output support. + * + * @param shellScript The shell command to execute + * @param stdWriter Writer for stdout + * @param errWriter Writer for stderr + * @param dispatcher Coroutine dispatcher for execution + * @return Exit code of the process + */ + suspend fun exec( + shellScript: String, + stdWriter: Writer, + errWriter: Writer, + dispatcher: CoroutineDispatcher + ): Int = withContext(dispatcher) { + val process = createProcess(shellScript) + + val exitCode = async { process.awaitExit() } + val errOutput = async { consumeProcessOutput(process.errorStream, errWriter, process, dispatcher) } + val stdOutput = async { consumeProcessOutput(process.inputStream, stdWriter, process, dispatcher) } + + stdOutput.await() + errOutput.await() + exitCode.await() + } + + /** + * Execute shell command and return result. + */ + suspend fun executeCommand(command: String, dispatcher: CoroutineDispatcher): ShellExecutorResult { + val stdWriter = StringWriter() + val errWriter = StringWriter() + + val exitCode = exec(command, stdWriter, errWriter, dispatcher) + return ShellExecutorResult( + exitCode = exitCode, + stdOutput = stdWriter.toString(), + errOutput = errWriter.toString() + ) + } + + private fun createProcess(shellScript: String): Process { + val basedir = project.basePath + val commandLine = PtyCommandLine() + commandLine.withConsoleMode(false) + commandLine.withUnixOpenTtyToPreserveOutputAfterTermination(true) + commandLine.withInitialColumns(240) + commandLine.withInitialRows(80) + commandLine.withEnvironment("TERM", "dumb") + commandLine.withEnvironment("BASH_SILENCE_DEPRECATION_WARNING", "1") + commandLine.withEnvironment("GIT_PAGER", "cat") + + // Set JAVA_HOME if available + try { + getJdkVersion()?.let { javaHomePath -> + commandLine.withEnvironment("JAVA_HOME", javaHomePath) + } + } catch (e: Exception) { + // Ignore JAVA_HOME errors + } + + val shell = detectShell() + val commands: List = listOf(shell, "--noprofile", "--norc", "-c", formatCommand(shellScript)) + + if (basedir != null) { + commandLine.withWorkDirectory(basedir) + } + + return commandLine.startProcessWithPty(commands) + } + + private fun formatCommand(command: String): String { + return "{ $command; } 2>&1" + } + + private fun detectShell(): String { + val shells = listOf("/bin/zsh", "/bin/bash", "/bin/sh") + return shells.find { File(it).exists() } ?: "bash" + } + + private fun getJdkVersion(): String? { + return try { + val sdk = ProjectRootManager.getInstance(project).projectSdk + sdk?.homePath + } catch (e: Exception) { + null + } + } + + private suspend fun consumeProcessOutput( + source: InputStream?, + outputWriter: Writer, + process: Process, + dispatcher: CoroutineDispatcher + ) = withContext(dispatcher) { + if (source == null) return@withContext + + var isFirstLine = true + BufferedReader(InputStreamReader(source, Charsets.UTF_8.name())).use { reader -> + do { + val line = reader.readLine() + if (Strings.isNotEmpty(line)) { + if (!isFirstLine) outputWriter.append(System.lineSeparator()) + isFirstLine = false + outputWriter.append(line) + } else { + yield() + } + ensureActive() + } while (process.isAlive || line != null) + } + } + + companion object { + @JvmStatic + fun getInstance(project: Project): IdeaShellExecutor = project.service() + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt new file mode 100644 index 0000000000..9e371c0025 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt @@ -0,0 +1,75 @@ +package cc.unitmesh.devins.idea.services + +import com.intellij.openapi.application.ApplicationManager +import java.io.StringWriter +import java.io.Writer + +/** + * A Writer implementation that updates UI components based on the output stream. + * Uses callbacks to abstract UI interactions. + * + * Adapted from UIUpdatingWriter in core module for use with Compose UI. + */ +class IdeaUIWriter( + private val onTextUpdate: (String, Boolean) -> Unit, + private val onStateUpdate: (IdeaTerminalExecutionState, String?) -> Unit +) : Writer() { + private val stringWriter = StringWriter() + private var isExecuting = false + + override fun write(cbuf: CharArray, off: Int, len: Int) { + stringWriter.write(cbuf, off, len) + updateUI() + } + + override fun flush() { + stringWriter.flush() + updateUI() + } + + override fun close() { + stringWriter.close() + isExecuting = false + updateUI() + } + + fun setExecuting(executing: Boolean) { + isExecuting = executing + if (executing) { + onStateUpdate(IdeaTerminalExecutionState.EXECUTING, null) + } + updateUI() + } + + fun setSuccess() { + isExecuting = false + onStateUpdate(IdeaTerminalExecutionState.SUCCESS, null) + updateUI() + } + + fun setFailed(message: String?) { + isExecuting = false + onStateUpdate(IdeaTerminalExecutionState.FAILED, message) + updateUI() + } + + fun setTerminated() { + isExecuting = false + onStateUpdate(IdeaTerminalExecutionState.TERMINATED, "Execution terminated by user") + updateUI() + } + + private fun updateUI() { + ApplicationManager.getApplication().invokeLater { + val currentText = stringWriter.toString() + onTextUpdate(currentText, !isExecuting) + } + } + + fun getContent(): String = stringWriter.toString() + + fun clear() { + stringWriter.buffer.setLength(0) + } +} + 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 c62b6058f0..9600f2dc6b 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 @@ -124,7 +124,8 @@ fun IdeaAgentApp( IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, - listState = listState + listState = listState, + project = project ) } AgentType.CODE_REVIEW -> { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index e4cb1e619e..4dbaf200a5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -444,5 +444,73 @@ object IdeaComposeIcons { } }.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() + } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt index ec25d07604..68d7d349bf 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt @@ -1,80 +1,369 @@ package cc.unitmesh.devins.idea.toolwindow.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.runtime.Composable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devins.idea.services.IdeaShellExecutor +import cc.unitmesh.devins.idea.services.IdeaTerminalExecutionState +import cc.unitmesh.devins.idea.services.IdeaUIWriter +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.intellij.openapi.project.Project +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection /** * Terminal output bubble for displaying shell command results. - * Similar to TerminalOutputItem in mpp-ui but using Jewel theming. + * Enhanced with execute/re-execute, expand/collapse, and copy functionality. + * Inspired by TerminalSketchProvider from ext-terminal module. */ @Composable fun IdeaTerminalOutputBubble( item: JewelRenderer.TimelineItem.TerminalOutputItem, + project: Project? = null, modifier: Modifier = Modifier ) { + var expanded by remember { mutableStateOf(item.exitCode != 0) } + var showFullOutput by remember { mutableStateOf(false) } + + // Execution state for re-run functionality + var executionState by remember { mutableStateOf(IdeaTerminalExecutionState.READY) } + var isExecuting by remember { mutableStateOf(false) } + var currentOutput by remember { mutableStateOf(item.output) } + var currentExitCode by remember { mutableStateOf(item.exitCode) } + var currentExecutionTime by remember { mutableStateOf(item.executionTimeMs) } + var executionJob by remember { mutableStateOf(null) } + + val coroutineScope = rememberCoroutineScope() + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start ) { Box( modifier = Modifier - .widthIn(max = 600.dp) + .widthIn(max = 700.dp) + .clip(RoundedCornerShape(8.dp)) .background(AutoDevColors.Neutral.c900) - .padding(8.dp) ) { Column { - // Command header - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + // Header with command and actions + TerminalHeader( + command = item.command, + exitCode = currentExitCode, + executionTimeMs = currentExecutionTime, + executionState = executionState, + isExecuting = isExecuting, + expanded = expanded, + onExpandToggle = { expanded = !expanded }, + onExecute = { + if (project != null && !isExecuting) { + isExecuting = true + executionState = IdeaTerminalExecutionState.EXECUTING + currentOutput = "" + + val uiWriter = IdeaUIWriter( + onTextUpdate = { text, _ -> currentOutput = text }, + onStateUpdate = { state, _ -> executionState = state } + ) + + val startTime = System.currentTimeMillis() + executionJob = coroutineScope.launch { + try { + val executor = IdeaShellExecutor.getInstance(project) + uiWriter.setExecuting(true) + val exitCode = executor.exec( + item.command, + uiWriter, + uiWriter, + Dispatchers.IO + ) + currentExitCode = exitCode + currentExecutionTime = System.currentTimeMillis() - startTime + if (exitCode == 0) { + uiWriter.setSuccess() + } else { + uiWriter.setFailed("Exit code: $exitCode") + } + } catch (e: Exception) { + uiWriter.setFailed(e.message) + currentExitCode = -1 + } finally { + isExecuting = false + expanded = true + } + } + } + }, + onStop = { + executionJob?.cancel() + isExecuting = false + executionState = IdeaTerminalExecutionState.TERMINATED + }, + onCopy = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(currentOutput), null) + } + ) + + // Collapsible output content + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically() ) { - Text( - text = "$ ${item.command}", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - color = AutoDevColors.Cyan.c400 - ) - ) - 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 + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF1E1E1E)) + .padding(12.dp) + ) { + // Output text + val displayOutput = if (showFullOutput || currentOutput.length <= 1000) { + currentOutput + } else { + currentOutput.take(1000) + "\n..." + } + + if (displayOutput.isNotEmpty()) { + Text( + text = displayOutput, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = AutoDevColors.Neutral.c300 + ) + ) + } else if (isExecuting) { + Text( + text = "Executing...", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = AutoDevColors.Neutral.c500 + ) + ) + } + + // Show more/less toggle + if (currentOutput.length > 1000) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (showFullOutput) "Show Less" else "Show Full Output", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = AutoDevColors.Blue.c400 + ), + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { showFullOutput = !showFullOutput } + ) + } + } + } + } + } + } +} + + +/** + * Header component for terminal bubble with command, status, and action buttons. + */ +@Composable +private fun TerminalHeader( + command: String, + exitCode: Int, + executionTimeMs: Long, + executionState: IdeaTerminalExecutionState, + isExecuting: Boolean, + expanded: Boolean, + onExpandToggle: () -> Unit, + onExecute: () -> Unit, + onStop: () -> Unit, + onCopy: () -> Unit +) { + 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 and status + 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 + ) { + // Execution state indicator + TerminalStatusBadge( + executionState = executionState, + exitCode = exitCode, + executionTimeMs = executionTimeMs, + isExecuting = isExecuting + ) + + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Execute/Stop button + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background( + if (isExecuting) AutoDevColors.Red.c600 + else AutoDevColors.Green.c600 ) + .clickable { + if (isExecuting) onStop() else onExecute() + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isExecuting) IdeaComposeIcons.Stop else IdeaComposeIcons.PlayArrow, + contentDescription = if (isExecuting) "Stop" else "Execute", + tint = Color.White, + modifier = Modifier.size(14.dp) ) } - 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 + // 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 execution state with appropriate colors. + */ +@Composable +private fun TerminalStatusBadge( + executionState: IdeaTerminalExecutionState, + exitCode: Int, + executionTimeMs: Long, + isExecuting: Boolean +) { + val (bgColor, textColor, text) = when { + isExecuting -> Triple( + AutoDevColors.Blue.c600.copy(alpha = 0.3f), + AutoDevColors.Blue.c400, + "Running..." + ) + executionState == IdeaTerminalExecutionState.SUCCESS || exitCode == 0 -> Triple( + AutoDevColors.Green.c600.copy(alpha = 0.3f), + AutoDevColors.Green.c400, + "exit: 0 ${executionTimeMs}ms" + ) + executionState == IdeaTerminalExecutionState.FAILED || exitCode != 0 -> Triple( + AutoDevColors.Red.c600.copy(alpha = 0.3f), + AutoDevColors.Red.c400, + "exit: $exitCode ${executionTimeMs}ms" + ) + executionState == IdeaTerminalExecutionState.TERMINATED -> Triple( + AutoDevColors.Amber.c600.copy(alpha = 0.3f), + AutoDevColors.Amber.c400, + "Terminated" + ) + else -> Triple( + AutoDevColors.Neutral.c700, + AutoDevColors.Neutral.c400, + "Ready" + ) + } + + 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/toolwindow/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt index 4d9f9a4c1c..87c7b7c485 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.JewelRenderer +import com.intellij.openapi.project.Project import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text @@ -22,6 +23,7 @@ fun IdeaTimelineContent( timeline: List, streamingOutput: String, listState: LazyListState, + project: Project? = null, modifier: Modifier = Modifier ) { if (timeline.isEmpty() && streamingOutput.isEmpty()) { @@ -34,7 +36,7 @@ fun IdeaTimelineContent( verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(timeline, key = { it.id }) { item -> - IdeaTimelineItemView(item) + IdeaTimelineItemView(item, project) } // Show streaming output @@ -51,7 +53,10 @@ fun IdeaTimelineContent( * Dispatch timeline item to appropriate bubble component. */ @Composable -fun IdeaTimelineItemView(item: JewelRenderer.TimelineItem) { +fun IdeaTimelineItemView( + item: JewelRenderer.TimelineItem, + project: Project? = null +) { when (item) { is JewelRenderer.TimelineItem.MessageItem -> { IdeaMessageBubble( @@ -69,7 +74,7 @@ fun IdeaTimelineItemView(item: JewelRenderer.TimelineItem) { IdeaTaskCompleteBubble(item) } is JewelRenderer.TimelineItem.TerminalOutputItem -> { - IdeaTerminalOutputBubble(item) + IdeaTerminalOutputBubble(item, project) } } } From 5fb1d1edfced7a33bdd25b6c9c7f820f3a4aa7bd Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 17:50:44 +0800 Subject: [PATCH 13/60] refactor(mpp-idea): simplify IdeaTerminalOutputBubble - Full width layout instead of max 700dp - Scrollable output area with 4 lines visible (~120dp) - Auto-scroll to bottom when output changes - Remove execute/re-run functionality (display only) - Remove IdeaShellExecutor and IdeaUIWriter services - Simplified header with command, status badge, and copy button --- .../devins/idea/services/IdeaShellExecutor.kt | 160 --------- .../devins/idea/services/IdeaUIWriter.kt | 75 ----- .../devins/idea/toolwindow/IdeaAgentApp.kt | 3 +- .../timeline/IdeaTerminalOutputBubble.kt | 303 +++++------------- .../timeline/IdeaTimelineContent.kt | 11 +- 5 files changed, 87 insertions(+), 465 deletions(-) delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt deleted file mode 100644 index 1fff4eafeb..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaShellExecutor.kt +++ /dev/null @@ -1,160 +0,0 @@ -package cc.unitmesh.devins.idea.services - -import com.intellij.execution.configurations.PtyCommandLine -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.service -import com.intellij.openapi.project.Project -import com.intellij.openapi.roots.ProjectRootManager -import com.intellij.openapi.util.text.Strings -import com.intellij.util.execution.ParametersListUtil -import com.intellij.util.io.awaitExit -import kotlinx.coroutines.* -import java.io.* - -/** - * Result from shell command execution. - */ -data class ShellExecutorResult( - val exitCode: Int, - val stdOutput: String, - val errOutput: String -) - -/** - * Execution state for terminal commands. - * Aligned with ext-terminal's TerminalExecutionState. - */ -enum class IdeaTerminalExecutionState { - READY, - EXECUTING, - SUCCESS, - FAILED, - TERMINATED -} - -/** - * Shell command executor service for mpp-idea. - * Based on ProcessExecutor from core module but adapted for standalone use. - */ -@Service(Service.Level.PROJECT) -class IdeaShellExecutor(private val project: Project) { - - /** - * Execute shell command with streaming output support. - * - * @param shellScript The shell command to execute - * @param stdWriter Writer for stdout - * @param errWriter Writer for stderr - * @param dispatcher Coroutine dispatcher for execution - * @return Exit code of the process - */ - suspend fun exec( - shellScript: String, - stdWriter: Writer, - errWriter: Writer, - dispatcher: CoroutineDispatcher - ): Int = withContext(dispatcher) { - val process = createProcess(shellScript) - - val exitCode = async { process.awaitExit() } - val errOutput = async { consumeProcessOutput(process.errorStream, errWriter, process, dispatcher) } - val stdOutput = async { consumeProcessOutput(process.inputStream, stdWriter, process, dispatcher) } - - stdOutput.await() - errOutput.await() - exitCode.await() - } - - /** - * Execute shell command and return result. - */ - suspend fun executeCommand(command: String, dispatcher: CoroutineDispatcher): ShellExecutorResult { - val stdWriter = StringWriter() - val errWriter = StringWriter() - - val exitCode = exec(command, stdWriter, errWriter, dispatcher) - return ShellExecutorResult( - exitCode = exitCode, - stdOutput = stdWriter.toString(), - errOutput = errWriter.toString() - ) - } - - private fun createProcess(shellScript: String): Process { - val basedir = project.basePath - val commandLine = PtyCommandLine() - commandLine.withConsoleMode(false) - commandLine.withUnixOpenTtyToPreserveOutputAfterTermination(true) - commandLine.withInitialColumns(240) - commandLine.withInitialRows(80) - commandLine.withEnvironment("TERM", "dumb") - commandLine.withEnvironment("BASH_SILENCE_DEPRECATION_WARNING", "1") - commandLine.withEnvironment("GIT_PAGER", "cat") - - // Set JAVA_HOME if available - try { - getJdkVersion()?.let { javaHomePath -> - commandLine.withEnvironment("JAVA_HOME", javaHomePath) - } - } catch (e: Exception) { - // Ignore JAVA_HOME errors - } - - val shell = detectShell() - val commands: List = listOf(shell, "--noprofile", "--norc", "-c", formatCommand(shellScript)) - - if (basedir != null) { - commandLine.withWorkDirectory(basedir) - } - - return commandLine.startProcessWithPty(commands) - } - - private fun formatCommand(command: String): String { - return "{ $command; } 2>&1" - } - - private fun detectShell(): String { - val shells = listOf("/bin/zsh", "/bin/bash", "/bin/sh") - return shells.find { File(it).exists() } ?: "bash" - } - - private fun getJdkVersion(): String? { - return try { - val sdk = ProjectRootManager.getInstance(project).projectSdk - sdk?.homePath - } catch (e: Exception) { - null - } - } - - private suspend fun consumeProcessOutput( - source: InputStream?, - outputWriter: Writer, - process: Process, - dispatcher: CoroutineDispatcher - ) = withContext(dispatcher) { - if (source == null) return@withContext - - var isFirstLine = true - BufferedReader(InputStreamReader(source, Charsets.UTF_8.name())).use { reader -> - do { - val line = reader.readLine() - if (Strings.isNotEmpty(line)) { - if (!isFirstLine) outputWriter.append(System.lineSeparator()) - isFirstLine = false - outputWriter.append(line) - } else { - yield() - } - ensureActive() - } while (process.isAlive || line != null) - } - } - - companion object { - @JvmStatic - fun getInstance(project: Project): IdeaShellExecutor = project.service() - } -} - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt deleted file mode 100644 index 9e371c0025..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/services/IdeaUIWriter.kt +++ /dev/null @@ -1,75 +0,0 @@ -package cc.unitmesh.devins.idea.services - -import com.intellij.openapi.application.ApplicationManager -import java.io.StringWriter -import java.io.Writer - -/** - * A Writer implementation that updates UI components based on the output stream. - * Uses callbacks to abstract UI interactions. - * - * Adapted from UIUpdatingWriter in core module for use with Compose UI. - */ -class IdeaUIWriter( - private val onTextUpdate: (String, Boolean) -> Unit, - private val onStateUpdate: (IdeaTerminalExecutionState, String?) -> Unit -) : Writer() { - private val stringWriter = StringWriter() - private var isExecuting = false - - override fun write(cbuf: CharArray, off: Int, len: Int) { - stringWriter.write(cbuf, off, len) - updateUI() - } - - override fun flush() { - stringWriter.flush() - updateUI() - } - - override fun close() { - stringWriter.close() - isExecuting = false - updateUI() - } - - fun setExecuting(executing: Boolean) { - isExecuting = executing - if (executing) { - onStateUpdate(IdeaTerminalExecutionState.EXECUTING, null) - } - updateUI() - } - - fun setSuccess() { - isExecuting = false - onStateUpdate(IdeaTerminalExecutionState.SUCCESS, null) - updateUI() - } - - fun setFailed(message: String?) { - isExecuting = false - onStateUpdate(IdeaTerminalExecutionState.FAILED, message) - updateUI() - } - - fun setTerminated() { - isExecuting = false - onStateUpdate(IdeaTerminalExecutionState.TERMINATED, "Execution terminated by user") - updateUI() - } - - private fun updateUI() { - ApplicationManager.getApplication().invokeLater { - val currentText = stringWriter.toString() - onTextUpdate(currentText, !isExecuting) - } - } - - fun getContent(): String = stringWriter.toString() - - fun clear() { - stringWriter.buffer.setLength(0) - } -} - 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 9600f2dc6b..c62b6058f0 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 @@ -124,8 +124,7 @@ fun IdeaAgentApp( IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, - listState = listState, - project = project + listState = listState ) } AgentType.CODE_REVIEW -> { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt index 68d7d349bf..503c2ee7df 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt @@ -7,7 +7,9 @@ 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.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 @@ -18,15 +20,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.JewelRenderer -import cc.unitmesh.devins.idea.services.IdeaShellExecutor -import cc.unitmesh.devins.idea.services.IdeaTerminalExecutionState -import cc.unitmesh.devins.idea.services.IdeaUIWriter import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import com.intellij.openapi.project.Project -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text @@ -35,152 +30,74 @@ import java.awt.datatransfer.StringSelection /** * Terminal output bubble for displaying shell command results. - * Enhanced with execute/re-execute, expand/collapse, and copy functionality. - * Inspired by TerminalSketchProvider from ext-terminal module. + * Shows output with scrollable area (4 lines visible), full width layout. */ @Composable fun IdeaTerminalOutputBubble( item: JewelRenderer.TimelineItem.TerminalOutputItem, - project: Project? = null, modifier: Modifier = Modifier ) { - var expanded by remember { mutableStateOf(item.exitCode != 0) } - var showFullOutput by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(true) } + val scrollState = rememberScrollState() - // Execution state for re-run functionality - var executionState by remember { mutableStateOf(IdeaTerminalExecutionState.READY) } - var isExecuting by remember { mutableStateOf(false) } - var currentOutput by remember { mutableStateOf(item.output) } - var currentExitCode by remember { mutableStateOf(item.exitCode) } - var currentExecutionTime by remember { mutableStateOf(item.executionTimeMs) } - var executionJob by remember { mutableStateOf(null) } - - val coroutineScope = rememberCoroutineScope() + // Auto-scroll to bottom when output changes + LaunchedEffect(item.output) { + scrollState.animateScrollTo(scrollState.maxValue) + } - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AutoDevColors.Neutral.c900) ) { - Box( - modifier = Modifier - .widthIn(max = 700.dp) - .clip(RoundedCornerShape(8.dp)) - .background(AutoDevColors.Neutral.c900) - ) { - Column { - // Header with command and actions - TerminalHeader( - command = item.command, - exitCode = currentExitCode, - executionTimeMs = currentExecutionTime, - executionState = executionState, - isExecuting = isExecuting, - expanded = expanded, - onExpandToggle = { expanded = !expanded }, - onExecute = { - if (project != null && !isExecuting) { - isExecuting = true - executionState = IdeaTerminalExecutionState.EXECUTING - currentOutput = "" - - val uiWriter = IdeaUIWriter( - onTextUpdate = { text, _ -> currentOutput = text }, - onStateUpdate = { state, _ -> executionState = state } - ) - - val startTime = System.currentTimeMillis() - executionJob = coroutineScope.launch { - try { - val executor = IdeaShellExecutor.getInstance(project) - uiWriter.setExecuting(true) - val exitCode = executor.exec( - item.command, - uiWriter, - uiWriter, - Dispatchers.IO - ) - currentExitCode = exitCode - currentExecutionTime = System.currentTimeMillis() - startTime - if (exitCode == 0) { - uiWriter.setSuccess() - } else { - uiWriter.setFailed("Exit code: $exitCode") - } - } catch (e: Exception) { - uiWriter.setFailed(e.message) - currentExitCode = -1 - } finally { - isExecuting = false - expanded = true - } - } - } - }, - onStop = { - executionJob?.cancel() - isExecuting = false - executionState = IdeaTerminalExecutionState.TERMINATED - }, - onCopy = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(currentOutput), null) - } - ) + Column { + // Header with command and status + TerminalHeader( + command = item.command, + exitCode = item.exitCode, + executionTimeMs = item.executionTimeMs, + expanded = expanded, + onExpandToggle = { expanded = !expanded }, + onCopy = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(item.output), null) + } + ) - // Collapsible output content - AnimatedVisibility( - visible = expanded, - enter = expandVertically(), - exit = shrinkVertically() + // Collapsible output content with scrolling + AnimatedVisibility( + visible = expanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 120.dp) // ~4 lines at 12sp + padding + .background(Color(0xFF1E1E1E)) + .padding(12.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF1E1E1E)) - .padding(12.dp) - ) { - // Output text - val displayOutput = if (showFullOutput || currentOutput.length <= 1000) { - currentOutput - } else { - currentOutput.take(1000) + "\n..." - } - - if (displayOutput.isNotEmpty()) { - Text( - text = displayOutput, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = AutoDevColors.Neutral.c300 - ) - ) - } else if (isExecuting) { - Text( - text = "Executing...", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = AutoDevColors.Neutral.c500 - ) - ) - } - - // Show more/less toggle - if (currentOutput.length > 1000) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (showFullOutput) "Show Less" else "Show Full Output", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = AutoDevColors.Blue.c400 - ), - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { showFullOutput = !showFullOutput } + if (item.output.isNotEmpty()) { + Text( + text = item.output, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = AutoDevColors.Neutral.c300, + lineHeight = 18.sp + ), + modifier = Modifier.verticalScroll(scrollState) + ) + } else { + Text( + text = "(No output)", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = AutoDevColors.Neutral.c500 ) - } + ) } } } @@ -190,19 +107,15 @@ fun IdeaTerminalOutputBubble( /** - * Header component for terminal bubble with command, status, and action buttons. + * Header component for terminal bubble with command, status, and copy button. */ @Composable private fun TerminalHeader( command: String, exitCode: Int, executionTimeMs: Long, - executionState: IdeaTerminalExecutionState, - isExecuting: Boolean, expanded: Boolean, onExpandToggle: () -> Unit, - onExecute: () -> Unit, - onStop: () -> Unit, onCopy: () -> Unit ) { Row( @@ -217,7 +130,7 @@ private fun TerminalHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Left side: Command and status + // Left side: Command Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -240,7 +153,7 @@ private fun TerminalHeader( ) // Command text (truncated if too long) - val displayCommand = if (command.length > 50) command.take(50) + "..." else command + val displayCommand = if (command.length > 60) command.take(60) + "..." else command Text( text = "$ $displayCommand", style = JewelTheme.defaultTextStyle.copy( @@ -252,103 +165,53 @@ private fun TerminalHeader( ) } - // Right side: Status and actions + // Right side: Status and copy Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - // Execution state indicator - TerminalStatusBadge( - executionState = executionState, - exitCode = exitCode, - executionTimeMs = executionTimeMs, - isExecuting = isExecuting - ) - - // Action buttons - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + // Status badge + TerminalStatusBadge(exitCode = exitCode, executionTimeMs = executionTimeMs) + + // Copy button + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)) + .background(AutoDevColors.Neutral.c700) + .clickable { onCopy() }, + contentAlignment = Alignment.Center ) { - // Execute/Stop button - Box( - modifier = Modifier - .size(24.dp) - .clip(RoundedCornerShape(4.dp)) - .background( - if (isExecuting) AutoDevColors.Red.c600 - else AutoDevColors.Green.c600 - ) - .clickable { - if (isExecuting) onStop() else onExecute() - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (isExecuting) IdeaComposeIcons.Stop else IdeaComposeIcons.PlayArrow, - contentDescription = if (isExecuting) "Stop" else "Execute", - tint = Color.White, - 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) - ) - } + Icon( + imageVector = IdeaComposeIcons.ContentCopy, + contentDescription = "Copy output", + tint = AutoDevColors.Neutral.c300, + modifier = Modifier.size(14.dp) + ) } } } } /** - * Status badge showing execution state with appropriate colors. + * Status badge showing exit code and execution time. */ @Composable private fun TerminalStatusBadge( - executionState: IdeaTerminalExecutionState, exitCode: Int, - executionTimeMs: Long, - isExecuting: Boolean + executionTimeMs: Long ) { val (bgColor, textColor, text) = when { - isExecuting -> Triple( - AutoDevColors.Blue.c600.copy(alpha = 0.3f), - AutoDevColors.Blue.c400, - "Running..." - ) - executionState == IdeaTerminalExecutionState.SUCCESS || exitCode == 0 -> Triple( + exitCode == 0 -> Triple( AutoDevColors.Green.c600.copy(alpha = 0.3f), AutoDevColors.Green.c400, "exit: 0 ${executionTimeMs}ms" ) - executionState == IdeaTerminalExecutionState.FAILED || exitCode != 0 -> Triple( + else -> Triple( AutoDevColors.Red.c600.copy(alpha = 0.3f), AutoDevColors.Red.c400, "exit: $exitCode ${executionTimeMs}ms" ) - executionState == IdeaTerminalExecutionState.TERMINATED -> Triple( - AutoDevColors.Amber.c600.copy(alpha = 0.3f), - AutoDevColors.Amber.c400, - "Terminated" - ) - else -> Triple( - AutoDevColors.Neutral.c700, - AutoDevColors.Neutral.c400, - "Ready" - ) } Box( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt index 87c7b7c485..4d9f9a4c1c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.JewelRenderer -import com.intellij.openapi.project.Project import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text @@ -23,7 +22,6 @@ fun IdeaTimelineContent( timeline: List, streamingOutput: String, listState: LazyListState, - project: Project? = null, modifier: Modifier = Modifier ) { if (timeline.isEmpty() && streamingOutput.isEmpty()) { @@ -36,7 +34,7 @@ fun IdeaTimelineContent( verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(timeline, key = { it.id }) { item -> - IdeaTimelineItemView(item, project) + IdeaTimelineItemView(item) } // Show streaming output @@ -53,10 +51,7 @@ fun IdeaTimelineContent( * Dispatch timeline item to appropriate bubble component. */ @Composable -fun IdeaTimelineItemView( - item: JewelRenderer.TimelineItem, - project: Project? = null -) { +fun IdeaTimelineItemView(item: JewelRenderer.TimelineItem) { when (item) { is JewelRenderer.TimelineItem.MessageItem -> { IdeaMessageBubble( @@ -74,7 +69,7 @@ fun IdeaTimelineItemView( IdeaTaskCompleteBubble(item) } is JewelRenderer.TimelineItem.TerminalOutputItem -> { - IdeaTerminalOutputBubble(item, project) + IdeaTerminalOutputBubble(item) } } } From 0340be06ff32d3e5658fe3c652411b06da0ca691 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 17:57:20 +0800 Subject: [PATCH 14/60] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace '+' with '✓' checkmark in IdeaToolLoadingStatusBar - Use IntelliJ CopyPasteManager instead of AWT Toolkit for clipboard - Add RoundedCornerShape to IdeaTaskCompleteBubble background - Simplify formatToolOutput to avoid breaking valid JSON/tables --- .../status/IdeaToolLoadingStatusBar.kt | 2 +- .../timeline/IdeaTaskCompleteBubble.kt | 6 +++-- .../timeline/IdeaTerminalOutputBubble.kt | 5 ++-- .../toolwindow/timeline/IdeaToolCallBubble.kt | 25 ++++++------------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt index 056ad0bbbf..c448fa1327 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt @@ -72,7 +72,7 @@ fun IdeaToolLoadingStatusBar( ) } else if (!toolStatus.isLoading && toolStatus.mcpServersLoaded > 0) { Text( - text = "+ All tools ready", + text = "✓ All tools ready", style = JewelTheme.defaultTextStyle.copy( fontSize = 11.sp, color = AutoDevColors.Green.c400 diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt index 8eff1ae2a6..02e3e7ce34 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt @@ -2,6 +2,7 @@ package cc.unitmesh.devins.idea.toolwindow.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 @@ -30,10 +31,11 @@ fun IdeaTaskCompleteBubble( Box( modifier = Modifier .background( - if (item.success) + color = if (item.success) AutoDevColors.Green.c400.copy(alpha = 0.2f) else - AutoDevColors.Red.c400.copy(alpha = 0.2f) + AutoDevColors.Red.c400.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) ) { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt index 503c2ee7df..4a46ba9d05 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt @@ -22,10 +22,10 @@ import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.JewelRenderer 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.Text -import java.awt.Toolkit import java.awt.datatransfer.StringSelection /** @@ -60,8 +60,7 @@ fun IdeaTerminalOutputBubble( expanded = expanded, onExpandToggle = { expanded = !expanded }, onCopy = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(item.output), null) + CopyPasteManager.getInstance().setContents(StringSelection(item.output)) } ) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt index a02b5a0498..fefe011586 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt @@ -20,11 +20,11 @@ import androidx.compose.ui.unit.sp import cc.unitmesh.devins.idea.renderer.JewelRenderer 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.Toolkit import java.awt.datatransfer.StringSelection /** @@ -364,36 +364,25 @@ fun IdeaCurrentToolCallItem( } /** - * Copy text to system clipboard (JVM implementation) + * Copy text to system clipboard using IntelliJ CopyPasteManager */ private fun copyToClipboard(text: String) { try { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(text), null) + CopyPasteManager.getInstance().setContents(StringSelection(text)) } catch (e: Exception) { // Ignore clipboard errors } } /** - * Format tool output for better readability + * 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.trim().startsWith("{") || output.trim().startsWith("[") -> { - try { - output.replace(",", ",\n") - .replace("{", "{\n ") - .replace("}", "\n}") - .replace("[", "[\n ") - .replace("]", "\n]") - } catch (e: Exception) { - output - } - } output.contains("|") -> output // Table format - output.contains("\n") -> output - output.length > 100 -> "${output.take(100)}..." + output.contains("\n") -> output // Already formatted + output.length > 100 -> "${output.take(100)}..." // Truncate long single-line output else -> output } } From 0066c0e818d03ce0860a394db0e593f27cad8d0c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 18:05:55 +0800 Subject: [PATCH 15/60] feat(mpp-idea): enhance IdeaBottomToolbar with proper icons and slash command support - Add Send, Folder, AlternateEmail, ArrowDropDown icons to IdeaComposeIcons - Replace emoji/text with proper Jewel Icon components in IdeaBottomToolbar - Add onSlashClick callback for / command trigger button - Update IdeaInputSection and IdeaAgentApp to support slash commands - Align toolbar features with mpp-ui BottomToolbar (except ModelSelector) --- .../devins/idea/editor/IdeaBottomToolbar.kt | 88 ++++++++++--- .../devins/idea/editor/IdeaInputSection.kt | 8 ++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 4 + .../idea/toolwindow/IdeaComposeIcons.kt | 123 ++++++++++++++++++ 4 files changed, 202 insertions(+), 21 deletions(-) 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..28646063cd 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,20 +2,26 @@ 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 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. - * + * Provides send/stop buttons, @ trigger for agent completion, / command trigger, settings, and token info. + * + * Layout: Workspace - Token Info - @ Symbol - / Symbol - Settings - Send Button + * * Uses Jewel components for native IntelliJ IDEA look and feel. */ @Composable @@ -25,6 +31,7 @@ fun IdeaBottomToolbar( isExecuting: Boolean = false, onStopClick: () -> Unit = {}, onAtClick: () -> Unit = {}, + onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, workspacePath: String? = null, totalTokens: Int? = null, @@ -53,18 +60,19 @@ fun IdeaBottomToolbar( Box( modifier = Modifier - .background( - JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f) - ) + .clip(RoundedCornerShape(4.dp)) + .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) + Icon( + imageVector = IdeaComposeIcons.Folder, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(12.dp) ) Text( text = projectName, @@ -79,6 +87,7 @@ fun IdeaBottomToolbar( if (totalTokens != null && totalTokens > 0) { Box( modifier = Modifier + .clip(RoundedCornerShape(4.dp)) .background(AutoDevColors.Blue.c400.copy(alpha = 0.2f)) .padding(horizontal = 8.dp, vertical = 4.dp) ) { @@ -111,9 +120,22 @@ fun IdeaBottomToolbar( IconButton( onClick = onAtClick, modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.AlternateEmail, + contentDescription = "@ Agent", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(18.dp) + ) + } + + // / trigger button for slash commands + IconButton( + onClick = onSlashClick, + modifier = Modifier.size(32.dp) ) { Text( - text = "@", + text = "/", style = JewelTheme.defaultTextStyle.copy( fontSize = 16.sp, fontWeight = FontWeight.Bold @@ -126,9 +148,11 @@ fun IdeaBottomToolbar( onClick = onSettingsClick, modifier = Modifier.size(32.dp) ) { - Text( - text = "⚙", - style = JewelTheme.defaultTextStyle.copy(fontSize = 14.sp) + Icon( + imageVector = IdeaComposeIcons.Settings, + contentDescription = "Settings", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) ) } @@ -138,12 +162,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 +186,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/IdeaInputSection.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt index 8b20f968a2..856f154cf3 100644 --- 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 @@ -37,6 +37,7 @@ fun IdeaInputSection( onSend: (String) -> Unit, onStop: () -> Unit = {}, onAtClick: () -> Unit = {}, + onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, workspacePath: String? = null, totalTokens: Int? = null, @@ -124,6 +125,13 @@ fun IdeaInputSection( } onAtClick() }, + onSlashClick = { + // Insert / character and trigger slash commands + textFieldState.edit { + append("/") + } + onSlashClick() + }, onSettingsClick = onSettingsClick, workspacePath = workspacePath, totalTokens = totalTokens 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 c62b6058f0..bfb1302adf 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 @@ -266,6 +266,10 @@ private fun IdeaDevInInputArea( devInInput?.appendText("@") onAtClick() }, + onSlashClick = { + // Insert / at current cursor position to trigger slash commands + devInInput?.appendText("/") + }, onSettingsClick = onSettingsClick, workspacePath = workspacePath, totalTokens = totalTokens diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 4dbaf200a5..a9fbae64c4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -512,5 +512,128 @@ object IdeaComposeIcons { } }.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() + } } From f2fc9cb6eb15c47e2abf1a559d3c1fd8cffaa0ef Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 18:23:31 +0800 Subject: [PATCH 16/60] feat(mpp-idea): add ModelSelector and ModelConfigDialog components - Add IdeaModelSelector for switching between LLM configurations - Add IdeaModelConfigDialog for configuring LLM models with provider, model name, API key, base URL, and advanced settings - Add new icons to IdeaComposeIcons: Check, Visibility, VisibilityOff, ExpandMore, ExpandLess - Integrate ModelSelector into IdeaBottomToolbar - Update IdeaAgentApp with model selector state management - Add setActiveConfig method to IdeaAgentViewModel - Remove duplicate ExpandMore/ExpandLess icons from IdeaComposeIcons --- .../devins/idea/editor/IdeaBottomToolbar.kt | 18 +- .../idea/editor/IdeaModelConfigDialog.kt | 447 ++++++++++++++++++ .../devins/idea/editor/IdeaModelSelector.kt | 193 ++++++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 49 +- .../idea/toolwindow/IdeaAgentViewModel.kt | 15 + .../idea/toolwindow/IdeaComposeIcons.kt | 118 +++++ 6 files changed, 835 insertions(+), 5 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelConfigDialog.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt 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 28646063cd..6af9bc6878 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 @@ -12,15 +12,16 @@ 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, / command trigger, settings, and token info. + * Provides send/stop buttons, @ trigger for agent completion, / command trigger, model selector, settings, and token info. * - * Layout: Workspace - Token Info - @ Symbol - / Symbol - Settings - Send Button + * Layout: Workspace - Token Info - ModelSelector - @ Symbol - / Symbol - Settings - Send Button * * Uses Jewel components for native IntelliJ IDEA look and feel. */ @@ -35,6 +36,11 @@ fun IdeaBottomToolbar( onSettingsClick: () -> Unit = {}, workspacePath: String? = null, totalTokens: Int? = null, + // Model selector props + availableConfigs: List = emptyList(), + currentConfigName: String? = null, + onConfigSelect: (NamedModelConfig) -> Unit = {}, + onConfigureClick: () -> Unit = {}, modifier: Modifier = Modifier ) { Row( @@ -116,6 +122,14 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { + // Model selector + IdeaModelSelector( + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) + // @ trigger button for agent completion IconButton( onClick = onAtClick, 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..bd6f778d74 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt @@ -0,0 +1,193 @@ +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.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 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. + */ +@Composable +fun IdeaModelSelector( + availableConfigs: List, + currentConfigName: String?, + onConfigSelect: (NamedModelConfig) -> Unit, + onConfigureClick: () -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + val currentConfig = remember(currentConfigName, availableConfigs) { + availableConfigs.find { it.name == currentConfigName } + } + + val displayText = remember(currentConfig) { + currentConfig?.model ?: "Configure Model" + } + + Box(modifier = modifier) { + // Main selector button + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.height(32.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = displayText, + style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), + maxLines = 1 + ) + Icon( + imageVector = IdeaComposeIcons.ArrowDropDown, + contentDescription = null, + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + } + + // Dropdown popup + if (expanded) { + Popup( + 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/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index bfb1302adf..576f997d10 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 @@ -12,6 +12,7 @@ import cc.unitmesh.devins.idea.editor.IdeaBottomToolbar import cc.unitmesh.devins.idea.editor.IdeaDevInInput import cc.unitmesh.devins.idea.editor.IdeaInputListener import cc.unitmesh.devins.idea.editor.IdeaInputTrigger +import cc.unitmesh.devins.idea.editor.IdeaModelConfigDialog import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.toolwindow.header.IdeaAgentTabsHeader @@ -20,6 +21,8 @@ import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel import cc.unitmesh.devins.idea.toolwindow.status.IdeaToolLoadingStatusBar import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.toolwindow.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 @@ -56,8 +59,18 @@ 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) } @@ -155,7 +168,13 @@ fun IdeaAgentApp( onSettingsClick = { viewModel.setShowConfigDialog(true) }, onAtClick = { // @ click triggers agent completion - placeholder for now - } + }, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = { config -> + viewModel.setActiveConfig(config.name) + }, + onConfigureClick = { viewModel.setShowConfigDialog(true) } ) } @@ -165,6 +184,21 @@ fun IdeaAgentApp( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp) ) } + + // 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) + } + ) + } } /** @@ -178,6 +212,7 @@ fun IdeaAgentApp( * - Token usage display * - Settings access * - Stop/Send button based on execution state + * - Model selector for switching between LLM configurations */ @Composable private fun IdeaDevInInputArea( @@ -189,7 +224,11 @@ private fun IdeaDevInInputArea( 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) } @@ -272,7 +311,11 @@ private fun IdeaDevInInputArea( }, 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..d86dae858c 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 @@ -430,6 +430,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 index a9fbae64c4..7170071a1b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -635,5 +635,123 @@ object IdeaComposeIcons { } }.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() + } + } From 05d6fbd1bf06b64280cb4bec3624c925651b70d0 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 20:10:40 +0800 Subject: [PATCH 17/60] feat(mpp-idea): add Jewel-themed markdown renderer with Mermaid support - Add multiplatform-markdown-renderer:0.38.1 dependency - Create JewelMarkdownColors using Jewel theme colors - Create JewelMarkdownTypography using Jewel text styles - Create JewelMarkdown wrapper for core markdown library - Create IdeaMarkdownRenderer with Mermaid block detection - Create MermaidRenderer using JCEF (JBCefBrowser) for diagram rendering - Create MermaidDiagramView Compose wrapper with SwingPanel integration - Support dark/light theme for Mermaid diagrams --- mpp-idea/build.gradle.kts | 14 ++ .../idea/renderer/IdeaMarkdownRenderer.kt | 144 ++++++++++++++++++ .../idea/renderer/MermaidDiagramView.kt | 121 +++++++++++++++ .../devins/idea/renderer/MermaidRenderer.kt | 114 ++++++++++++++ .../idea/renderer/markdown/JewelMarkdown.kt | 69 +++++++++ .../renderer/markdown/JewelMarkdownColors.kt | 27 ++++ .../markdown/JewelMarkdownTypography.kt | 68 +++++++++ 7 files changed, 557 insertions(+) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/IdeaMarkdownRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidDiagramView.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/MermaidRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index c6e5e0563c..f119d49db8 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -105,6 +105,20 @@ dependencies { compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") + // Markdown renderer for Jewel-themed markdown rendering + implementation("com.mikepenz:multiplatform-markdown-renderer:0.38.1") { + // Exclude Compose dependencies - IntelliJ provides its own + exclude(group = "org.jetbrains.compose") + exclude(group = "org.jetbrains.compose.runtime") + exclude(group = "org.jetbrains.compose.foundation") + exclude(group = "org.jetbrains.compose.material3") + exclude(group = "org.jetbrains.compose.material") + exclude(group = "org.jetbrains.compose.ui") + exclude(group = "org.jetbrains.skiko") + // Exclude kotlinx libraries - IntelliJ provides its own + exclude(group = "org.jetbrains.kotlinx") + } + // SQLite JDBC driver for SQLDelight (required at runtime) implementation("org.xerial:sqlite-jdbc:3.49.1.0") 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..37a5424555 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/IdeaMarkdownRenderer.kt @@ -0,0 +1,144 @@ +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.JewelMarkdown +import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownColor +import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownTypography +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 standard JewelMarkdown for regular content or streaming + JewelMarkdown( + content = content, + modifier = modifier, + colors = jewelMarkdownColor(), + typography = jewelMarkdownTypography() + ) + } +} + +/** + * 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()) { + JewelMarkdown( + content = fence.text, + modifier = Modifier.fillMaxWidth(), + colors = jewelMarkdownColor(), + typography = jewelMarkdownTypography() + ) + } + } + 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/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/JewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt new file mode 100644 index 0000000000..d43b115112 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt @@ -0,0 +1,69 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.mikepenz.markdown.compose.Markdown +import com.mikepenz.markdown.compose.MarkdownSuccess +import com.mikepenz.markdown.compose.components.MarkdownComponents +import com.mikepenz.markdown.compose.components.markdownComponents +import com.mikepenz.markdown.model.* +import org.intellij.markdown.flavours.MarkdownFlavourDescriptor +import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor +import org.intellij.markdown.parser.MarkdownParser + +/** + * Renders the markdown content using Jewel (IntelliJ IDEA) styles. + * + * This is a wrapper around the core multiplatform-markdown-renderer library + * that provides IntelliJ-native theme colors and typography using Jewel. + * + * @param content The markdown content to be rendered. + * @param modifier The [Modifier] to apply to the component. + * @param colors The [MarkdownColors] to use for styling. Defaults to Jewel colors. + * @param typography The [MarkdownTypography] to use for text styles. Defaults to Jewel typography. + */ +@Composable +fun JewelMarkdown( + content: String, + modifier: Modifier = Modifier.fillMaxSize(), + colors: MarkdownColors = jewelMarkdownColor(), + typography: MarkdownTypography = jewelMarkdownTypography(), + padding: MarkdownPadding = markdownPadding(), + dimens: MarkdownDimens = markdownDimens(), + flavour: MarkdownFlavourDescriptor = GFMFlavourDescriptor(), + parser: MarkdownParser = MarkdownParser(flavour), + imageTransformer: ImageTransformer = NoOpImageTransformerImpl(), + annotator: MarkdownAnnotator = markdownAnnotator(), + extendedSpans: MarkdownExtendedSpans = markdownExtendedSpans(), + inlineContent: MarkdownInlineContent = markdownInlineContent(), + components: MarkdownComponents = markdownComponents(), + animations: MarkdownAnimations = markdownAnimations(), + referenceLinkHandler: ReferenceLinkHandler = ReferenceLinkHandlerImpl(), + loading: @Composable (modifier: Modifier) -> Unit = { Box(modifier) }, + success: @Composable (state: State.Success, components: MarkdownComponents, modifier: Modifier) -> Unit = { state, comps, mod -> + MarkdownSuccess(state = state, components = comps, modifier = mod) + }, + error: @Composable (modifier: Modifier) -> Unit = { Box(modifier) }, +) = Markdown( + content = content, + colors = colors, + typography = typography, + modifier = modifier, + padding = padding, + dimens = dimens, + flavour = flavour, + parser = parser, + imageTransformer = imageTransformer, + annotator = annotator, + extendedSpans = extendedSpans, + inlineContent = inlineContent, + components = components, + animations = animations, + referenceLinkHandler = referenceLinkHandler, + loading = loading, + success = success, + error = error, +) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt new file mode 100644 index 0000000000..1b597c112f --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt @@ -0,0 +1,27 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.mikepenz.markdown.model.DefaultMarkdownColors +import com.mikepenz.markdown.model.MarkdownColors +import org.jetbrains.jewel.foundation.theme.JewelTheme + +/** + * Creates a [MarkdownColors] instance using Jewel theme colors. + * Provides IntelliJ-native color scheme for markdown rendering. + */ +@Composable +fun jewelMarkdownColor( + text: Color = JewelTheme.globalColors.text.normal, + codeBackground: Color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + inlineCodeBackground: Color = codeBackground, + dividerColor: Color = JewelTheme.globalColors.borders.normal, + tableBackground: Color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), +): MarkdownColors = DefaultMarkdownColors( + text = text, + codeBackground = codeBackground, + inlineCodeBackground = inlineCodeBackground, + dividerColor = dividerColor, + tableBackground = tableBackground, +) + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt new file mode 100644 index 0000000000..d136290312 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt @@ -0,0 +1,68 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +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.sp +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import com.mikepenz.markdown.model.DefaultMarkdownTypography +import com.mikepenz.markdown.model.MarkdownTypography +import org.jetbrains.jewel.foundation.theme.JewelTheme + +/** + * Creates a [MarkdownTypography] instance using Jewel theme typography. + * Provides IntelliJ-native typography styles for markdown rendering. + */ +@Composable +fun jewelMarkdownTypography( + baseStyle: TextStyle = JewelTheme.defaultTextStyle, + h1: TextStyle = baseStyle.copy(fontSize = 28.sp, fontWeight = FontWeight.Bold), + h2: TextStyle = baseStyle.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold), + h3: TextStyle = baseStyle.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), + h4: TextStyle = baseStyle.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold), + h5: TextStyle = baseStyle.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold), + h6: TextStyle = baseStyle.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), + text: TextStyle = baseStyle.copy(fontSize = 14.sp), + code: TextStyle = baseStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 13.sp), + inlineCode: TextStyle = text.copy(fontFamily = FontFamily.Monospace), + quote: TextStyle = baseStyle.copy( + fontStyle = FontStyle.Italic, + fontSize = 14.sp, + color = JewelTheme.globalColors.text.info + ), + paragraph: TextStyle = text, + ordered: TextStyle = text, + bullet: TextStyle = text, + list: TextStyle = text, + textLink: TextLinkStyles = TextLinkStyles( + style = SpanStyle( + color = AutoDevColors.Blue.c400, + fontWeight = FontWeight.Medium, + textDecoration = TextDecoration.Underline + ) + ), + table: TextStyle = text, +): MarkdownTypography = DefaultMarkdownTypography( + h1 = h1, + h2 = h2, + h3 = h3, + h4 = h4, + h5 = h5, + h6 = h6, + text = text, + quote = quote, + code = code, + inlineCode = inlineCode, + paragraph = paragraph, + ordered = ordered, + bullet = bullet, + list = list, + textLink = textLink, + table = table, +) + From 0b3eb7897fb76658361304bdc6da212912c446e2 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 20:22:45 +0800 Subject: [PATCH 18/60] feat(mpp-idea): add Jewel-themed sketch renderers for IntelliJ IDEA - Add IdeaSketchRenderer as main dispatcher for content block types - Add IdeaCodeBlockRenderer for code blocks with Jewel styling - Add IdeaDiffRenderer for unified diff rendering with syntax highlighting - Add IdeaThinkingBlockRenderer for collapsible thinking process display - Add IdeaWalkthroughBlockRenderer wrapping IdeaMarkdownRenderer - Integrate IdeaSketchRenderer into IdeaCodeReviewContent for AI analysis output - Support markdown, diff, thinking, walkthrough, mermaid, and code blocks --- .../renderer/sketch/IdeaCodeBlockRenderer.kt | 53 ++++++++ .../idea/renderer/sketch/IdeaDiffRenderer.kt | 124 +++++++++++++++++ .../renderer/sketch/IdeaSketchRenderer.kt | 125 ++++++++++++++++++ .../sketch/IdeaThinkingBlockRenderer.kt | 111 ++++++++++++++++ .../sketch/IdeaWalkthroughBlockRenderer.kt | 35 +++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 5 +- .../codereview/IdeaCodeReviewContent.kt | 24 ++-- 7 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaCodeBlockRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDiffRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaThinkingBlockRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaWalkthroughBlockRenderer.kt 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/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..8d41a66ff4 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -0,0 +1,125 @@ +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.JewelMarkdown +import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownColor +import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownTypography +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 + */ +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()) { + JewelMarkdown( + content = fence.text, + modifier = Modifier.fillMaxWidth(), + colors = jewelMarkdownColor(), + typography = jewelMarkdownTypography() + ) + 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)) + } + } + + 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/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 576f997d10..33a7182d62 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 @@ -142,7 +142,10 @@ fun IdeaAgentApp( } AgentType.CODE_REVIEW -> { codeReviewViewModel?.let { vm -> - IdeaCodeReviewContent(viewModel = vm) + IdeaCodeReviewContent( + viewModel = vm, + parentDisposable = viewModel + ) } ?: IdeaEmptyStateMessage("Loading Code Review...") } AgentType.KNOWLEDGE -> { 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 44537f732e..006475312c 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 @@ -16,11 +16,13 @@ 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.idea.renderer.sketch.IdeaSketchRenderer import cc.unitmesh.devins.ui.compose.agent.codereview.AIAnalysisProgress import cc.unitmesh.devins.ui.compose.agent.codereview.AnalysisStage 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 com.intellij.openapi.Disposable import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.Orientation import org.jetbrains.jewel.ui.component.* @@ -30,7 +32,10 @@ import org.jetbrains.jewel.ui.component.* * Uses Jewel UI components for IntelliJ-native look and feel. */ @Composable -fun IdeaCodeReviewContent(viewModel: IdeaCodeReviewViewModel) { +fun IdeaCodeReviewContent( + viewModel: IdeaCodeReviewViewModel, + parentDisposable: Disposable +) { val state by viewModel.state.collectAsState() Row(modifier = Modifier.fillMaxSize()) { @@ -64,6 +69,7 @@ fun IdeaCodeReviewContent(viewModel: IdeaCodeReviewViewModel) { error = state.error, onStartAnalysis = { viewModel.startAnalysis() }, onCancelAnalysis = { viewModel.cancelAnalysis() }, + parentDisposable = parentDisposable, modifier = Modifier.width(350.dp).fillMaxHeight() ) } @@ -319,6 +325,7 @@ private fun AIAnalysisPanel( error: String?, onStartAnalysis: () -> Unit, onCancelAnalysis: () -> Unit, + parentDisposable: Disposable, modifier: Modifier = Modifier ) { Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { @@ -395,7 +402,7 @@ private fun AIAnalysisPanel( Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - // Analysis output + // Analysis output - use IdeaSketchRenderer for rich markdown/code rendering val scrollState = rememberScrollState() Box( modifier = Modifier @@ -404,12 +411,13 @@ private fun AIAnalysisPanel( .padding(12.dp) ) { if (progress.analysisOutput.isNotEmpty()) { - Text( - text = progress.analysisOutput, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp - ) + val isComplete = progress.stage == AnalysisStage.COMPLETED || + progress.stage == AnalysisStage.ERROR + IdeaSketchRenderer.RenderResponse( + content = progress.analysisOutput, + isComplete = isComplete, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() ) } else { Text( From c2e3c0242cd85ec9149345922048b11fbd8fbf06 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 13:33:58 +0000 Subject: [PATCH 19/60] feat(document): add ProductFeatureTreeAgent for automatic feature tree generation This PR implements Issue #14 - adding ProductFeatureTreeAgent capability to DocumentAgent. ## New Features ### ProductFeatureTreeAgent Mode - Added new feature-tree mode to DocumentAgent for generating product feature trees - AI-driven analysis using ReAct (Reasoning + Acting) workflow - No hardcoded judgments - LLM autonomously analyzes codebase structure - Multi-format output support: Mermaid MindMap, PlantUML, Graphviz DOT, Markdown, JSON ### Data Models (ProductFeature.kt) - ProductFeature: Hierarchical feature node with id, name, description, level, confidence, children, codeRefs - FeatureLevel enum: PRODUCT to MODULE to FEATURE to ATOMIC - FeatureStatus enum: PENDING/ANALYZING/CONFIRMED/NEEDS_REVIEW - CodeRef: Source code tracing with file path and inference source - FeatureTreeRenderer: Multi-format output rendering ### Prompt Templates (ProductFeatureTreeTemplate.kt) - Complete ReAct-style System Prompt - Bilingual support (EN/ZH) - Confidence scoring guidelines (1.0/0.8/0.6/0.4) - Skip rules for test, build, utils directories - Tool usage format specifications ## Bug Fixes ### ToolCallParser.kt - Fixed JSON parsing for single-line format like json followed by JSON on same line - Now handles both standard and compact JSON code block formats ### ToolOrchestrator.kt - Fixed glob tool parameter passing (was only passing pattern, now passes all params) - Fixed grep tool number type conversion for maxMatches/contextLines - Fixed read-file tool number type conversion for startLine/endLine/maxLines ### build.gradle.kts - Fixed parameter naming conflict with Gradle reserved projectPath - Added docMode and docLanguage parameters for CLI Closes #14 --- .../unitmesh/agent/document/DocumentAgent.kt | 47 +++- .../unitmesh/agent/document/ProductFeature.kt | 180 ++++++++++++ .../document/ProductFeatureTreeTemplate.kt | 259 ++++++++++++++++++ .../agent/orchestrator/ToolOrchestrator.kt | 19 +- .../unitmesh/agent/parser/ToolCallParser.kt | 18 ++ mpp-ui/build.gradle.kts | 11 +- .../cc/unitmesh/server/cli/DocumentCli.kt | 57 +++- 7 files changed, 564 insertions(+), 27 deletions(-) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeatureTreeTemplate.kt 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..efdfb4e152 --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt @@ -0,0 +1,180 @@ +package cc.unitmesh.agent.document + +import kotlinx.serialization.Serializable + +/** + * 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 { + private var counter = 0 + fun generateFeatureId(): String = "feature-${++counter}" + } +} + +/** + * 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}\", 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/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-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/jvmMain/kotlin/cc/unitmesh/server/cli/DocumentCli.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/server/cli/DocumentCli.kt index 9ab393b45d..c27685e041 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,55 @@ 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 + * # Document query mode (default) * ./gradlew :mpp-server:runDocumentCli -PprojectPath=/path/to/docs -Pquery="What is this about?" [-PdocumentPath=specific.pptx] + * + * # Feature tree mode - generate product feature tree from source code + * ./gradlew :mpp-server:runDocumentCli -PprojectPath=/path/to/project -Pmode=feature-tree [-Planguage=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: -PprojectPath= -Pquery= [-PdocumentPath=] [-Pmode=feature-tree] [-Planguage=EN|ZH]") return } - val query = System.getProperty("query") ?: args.getOrNull(1) ?: run { - System.err.println("Usage: -PprojectPath= -Pquery= [-PdocumentPath=]") - return + + // Check mode + val mode = System.getProperty("mode") ?: args.getOrNull(3) ?: "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: -PprojectPath= -Pquery= [-PdocumentPath=] [-Pmode=feature-tree] [-Planguage=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 +260,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 = { } ) From 6eae8d6f08bbfc8c90171f5236ed1a7e9d5b0008 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Sun, 30 Nov 2025 14:00:34 +0000 Subject: [PATCH 20/60] fix: address PR review comments 1. Thread-safety: Use AtomicInt.addAndFetch(1) for thread-safe counter 2. DOT graph: Add style=filled to child nodes for fillcolor visibility 3. Documentation: Fix CLI usage examples with correct module name and parameter names --- .../cc/unitmesh/agent/document/ProductFeature.kt | 11 ++++++++--- .../kotlin/cc/unitmesh/server/cli/DocumentCli.kt | 14 ++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) 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 index efdfb4e152..6dd8eab7b6 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/document/ProductFeature.kt @@ -1,6 +1,8 @@ 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 @@ -17,8 +19,11 @@ data class ProductFeature( val status: FeatureStatus = FeatureStatus.PENDING ) { companion object { - private var counter = 0 - fun generateFeatureId(): String = "feature-${++counter}" + @OptIn(ExperimentalAtomicApi::class) + private val counter = AtomicInt(0) + + @OptIn(ExperimentalAtomicApi::class) + fun generateFeatureId(): String = "feature-${counter.addAndFetch(1)}" } } @@ -143,7 +148,7 @@ object FeatureTreeRenderer { 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}\", fillcolor=$color];") + sb.appendLine(" \"${child.id}\" [label=\"${child.name}\", style=filled, fillcolor=$color];") sb.appendLine(" \"${parent.id}\" -> \"${child.id}\";") renderDotChildren(child, sb) } 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 c27685e041..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 @@ -26,10 +26,10 @@ import java.security.MessageDigest * Usage: * ```bash * # Document query mode (default) - * ./gradlew :mpp-server:runDocumentCli -PprojectPath=/path/to/docs -Pquery="What is this about?" [-PdocumentPath=specific.pptx] + * ./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-server:runDocumentCli -PprojectPath=/path/to/project -Pmode=feature-tree [-Planguage=ZH] + * ./gradlew :mpp-ui:runDocumentCli -PdocProjectPath=/path/to/project -PdocMode=feature-tree [-PdocLanguage=ZH] * ``` */ object DocumentCli { @@ -42,12 +42,14 @@ object DocumentCli { // Parse arguments val projectPath = System.getProperty("projectPath") ?: args.getOrNull(0) ?: run { - System.err.println("Usage: -PprojectPath= -Pquery= [-PdocumentPath=] [-Pmode=feature-tree] [-Planguage=EN|ZH]") + System.err.println("Usage: -PdocProjectPath= -PdocQuery= [-PdocPath=] [-PdocMode=feature-tree] [-PdocLanguage=EN|ZH]") return } - // Check mode - val mode = System.getProperty("mode") ?: args.getOrNull(3) ?: "query" + // 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) @@ -55,7 +57,7 @@ object DocumentCli { "Generate product feature tree for this codebase" } else { System.getProperty("query") ?: args.getOrNull(1) ?: run { - System.err.println("Usage: -PprojectPath= -Pquery= [-PdocumentPath=] [-Pmode=feature-tree] [-Planguage=EN|ZH]") + System.err.println("Usage: -PdocProjectPath= -PdocQuery= [-PdocPath=] [-PdocMode=feature-tree] [-PdocLanguage=EN|ZH]") return } } From 77f7b0132af0d8f9261eca8a34dd5099e1055a45 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 22:11:38 +0800 Subject: [PATCH 21/60] feat(mpp-idea): redesign IdeaAgentTabsHeader with modern segmented control - Add segmented control design with unified background container - Implement smooth color and size animations using spring physics - Add unique color and icon for each agent type: - Agentic: Blue with code icon - Review: Indigo with review/magnifier icon - Knowledge: Green with book icon - Remote: Amber with cloud icon - Add hover effects for tabs and action buttons - Add animated bottom indicator for selected tab - Add new icons to IdeaComposeIcons: Add, Review, Book, Cloud, Chat --- .../idea/toolwindow/IdeaComposeIcons.kt | 175 ++++++++++++ .../toolwindow/header/IdeaAgentTabsHeader.kt | 251 +++++++++++++++--- 2 files changed, 382 insertions(+), 44 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 7170071a1b..f54c2af249 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -753,5 +753,180 @@ object IdeaComposeIcons { }.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 { + path( + fill = SolidColor(Color.Black) + ) { + // Magnifying glass + 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 + moveTo(7.5f, 9.5f) + lineToRelative(1.5f, 1.5f) + lineToRelative(3f, -3f) + } + 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() + } + } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt index fd253466a0..32e6fe0e80 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt @@ -1,25 +1,36 @@ package cc.unitmesh.devins.idea.toolwindow.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.runtime.Composable +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.OutlinedButton import org.jetbrains.jewel.ui.component.Text -import org.jetbrains.jewel.ui.theme.defaultBannerStyle /** * Agent tabs header for switching between agent types. - * Similar to AgentTopAppBar in mpp-ui but using Jewel theming. + * Modern segmented control design with smooth animations and hover effects. */ @Composable fun IdeaAgentTabsHeader( @@ -32,76 +43,228 @@ fun IdeaAgentTabsHeader( Row( modifier = modifier .fillMaxWidth() - .height(36.dp) + .height(40.dp) .padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Left: Agent Type Tabs + // Left: Segmented Agent Type Control + SegmentedAgentTabs( + currentAgentType = currentAgentType, + onAgentTypeChange = onAgentTypeChange + ) + + // Right: Actions with tooltips Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically ) { - // Show only main agent types for cleaner UI - listOf(AgentType.CODING, AgentType.CODE_REVIEW, AgentType.KNOWLEDGE, AgentType.REMOTE).forEach { type -> - IdeaAgentTab( - type = type, - isSelected = type == currentAgentType, - onClick = { onAgentTypeChange(type) } - ) - } + // 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 +) { + 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() - // Right: Actions + // 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 ) { - IconButton(onClick = onNewChat) { - Text("+", style = JewelTheme.defaultTextStyle.copy(fontWeight = FontWeight.Bold)) - } - IconButton(onClick = onSettings) { - Icon( - imageVector = IdeaComposeIcons.Settings, - contentDescription = "Settings", - modifier = Modifier.size(16.dp), - tint = JewelTheme.globalColors.text.normal + // 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)) + ) } } /** - * Individual agent tab button. + * Fancy action button with hover effect. */ @Composable -fun IdeaAgentTab( - type: AgentType, - isSelected: Boolean, +private fun FancyActionButton( + icon: ImageVector, + contentDescription: String, onClick: () -> Unit, modifier: Modifier = Modifier ) { - val backgroundColor = if (isSelected) { - JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.5f) - } else { - JewelTheme.globalColors.panelBackground - } + 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" + ) - OutlinedButton( + 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 - .height(28.dp) + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) .background(backgroundColor) - .padding(horizontal = 4.dp, vertical = 2.dp) + .hoverable(interactionSource) ) { - Text( - text = type.getDisplayName(), - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) + 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 +} + From 18a10c64ab2f16e0a3e6665bf7ceac7e40f32ef5 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 22:21:41 +0800 Subject: [PATCH 22/60] fix(mpp-idea): address PR review comments - Fix Review icon: remove checkmark from fill path to avoid visual artifacts - Update comment: change 'Actions with tooltips' to 'Action buttons' - Add documentation: explain why LOCAL_CHAT is excluded from tabs --- .../cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt | 7 ++----- .../devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt | 5 ++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index f54c2af249..829b3f4513 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -795,10 +795,10 @@ object IdeaComposeIcons { viewportWidth = 24f, viewportHeight = 24f ).apply { + // Magnifying glass (fill path only, checkmark is drawn separately as stroke) path( fill = SolidColor(Color.Black) ) { - // Magnifying glass moveTo(15.5f, 14f) horizontalLineToRelative(-0.79f) lineToRelative(-0.28f, -0.27f) @@ -820,11 +820,8 @@ object IdeaComposeIcons { reflectiveCurveTo(14f, 7.01f, 14f, 9.5f) reflectiveCurveTo(11.99f, 14f, 9.5f, 14f) close() - // Checkmark inside - moveTo(7.5f, 9.5f) - lineToRelative(1.5f, 1.5f) - lineToRelative(3f, -3f) } + // Checkmark inside (stroke only to avoid visual artifacts) path( stroke = SolidColor(Color.Black), strokeLineWidth = 1.5f, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt index 32e6fe0e80..cb9c33e3a1 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt @@ -54,7 +54,7 @@ fun IdeaAgentTabsHeader( onAgentTypeChange = onAgentTypeChange ) - // Right: Actions with tooltips + // Right: Action buttons Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically @@ -86,6 +86,9 @@ private fun SegmentedAgentTabs( 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( From 8f04e39e887d01a668d8b49ad659f9ef6d1edeed Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 23:27:51 +0800 Subject: [PATCH 23/60] feat(mpp-idea): redesign DiffViewerPanel with commit info, file list/tree views, and issue display - Add IdeaResizableSplitPane and IdeaVerticalResizableSplitPane components - Redesign DiffViewerPanel to match DiffCenterView from mpp-ui: - Add commit info card with issue display - Add file view mode toggle (list/tree) - Add expandable file list with diff hunks - Add tree view with directory grouping - Add issue loading/error states with refresh - Add new icons: List, AccountTree, Edit, DriveFileRenameOutline, FolderOpen, ChevronRight, BugReport, Info - Replace mikepenz markdown library with SimpleJewelMarkdown to fix NoSuchMethodError - Add openFileViewer method to IdeaCodeReviewViewModel --- mpp-idea/build.gradle.kts | 15 +- .../idea/renderer/IdeaMarkdownRenderer.kt | 18 +- .../idea/renderer/markdown/JewelMarkdown.kt | 69 - .../renderer/markdown/JewelMarkdownColors.kt | 27 - .../markdown/JewelMarkdownTypography.kt | 68 - .../renderer/markdown/SimpleJewelMarkdown.kt | 220 ++ .../renderer/sketch/IdeaSketchRenderer.kt | 10 +- .../idea/toolwindow/IdeaComposeIcons.kt | 330 +++ .../codereview/IdeaCodeReviewContent.kt | 1913 +++++++++++++++-- .../codereview/IdeaCodeReviewViewModel.kt | 14 + .../components/IdeaResizableSplitPane.kt | 141 ++ .../IdeaVerticalResizableSplitPane.kt | 141 ++ 12 files changed, 2551 insertions(+), 415 deletions(-) delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaVerticalResizableSplitPane.kt diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index f119d49db8..7df79f882f 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -105,19 +105,8 @@ dependencies { compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") - // Markdown renderer for Jewel-themed markdown rendering - implementation("com.mikepenz:multiplatform-markdown-renderer:0.38.1") { - // Exclude Compose dependencies - IntelliJ provides its own - exclude(group = "org.jetbrains.compose") - exclude(group = "org.jetbrains.compose.runtime") - exclude(group = "org.jetbrains.compose.foundation") - exclude(group = "org.jetbrains.compose.material3") - exclude(group = "org.jetbrains.compose.material") - exclude(group = "org.jetbrains.compose.ui") - exclude(group = "org.jetbrains.skiko") - // Exclude kotlinx libraries - IntelliJ provides its own - exclude(group = "org.jetbrains.kotlinx") - } + // 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") 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 index 37a5424555..17d8b58e18 100644 --- 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 @@ -8,9 +8,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cc.unitmesh.devins.idea.renderer.markdown.JewelMarkdown -import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownColor -import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownTypography +import cc.unitmesh.devins.idea.renderer.markdown.SimpleJewelMarkdown import cc.unitmesh.devins.parser.CodeFence import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.Disposable @@ -47,12 +45,10 @@ fun IdeaMarkdownRenderer( modifier = modifier ) } else { - // Use standard JewelMarkdown for regular content or streaming - JewelMarkdown( + // Use simple Jewel Markdown renderer + SimpleJewelMarkdown( content = content, - modifier = modifier, - colors = jewelMarkdownColor(), - typography = jewelMarkdownTypography() + modifier = modifier ) } } @@ -84,11 +80,9 @@ private fun MermaidAwareMarkdownRenderer( } "markdown", "md", "" -> { if (fence.text.isNotBlank()) { - JewelMarkdown( + SimpleJewelMarkdown( content = fence.text, - modifier = Modifier.fillMaxWidth(), - colors = jewelMarkdownColor(), - typography = jewelMarkdownTypography() + modifier = Modifier.fillMaxWidth() ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt deleted file mode 100644 index d43b115112..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdown.kt +++ /dev/null @@ -1,69 +0,0 @@ -package cc.unitmesh.devins.idea.renderer.markdown - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.mikepenz.markdown.compose.Markdown -import com.mikepenz.markdown.compose.MarkdownSuccess -import com.mikepenz.markdown.compose.components.MarkdownComponents -import com.mikepenz.markdown.compose.components.markdownComponents -import com.mikepenz.markdown.model.* -import org.intellij.markdown.flavours.MarkdownFlavourDescriptor -import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor -import org.intellij.markdown.parser.MarkdownParser - -/** - * Renders the markdown content using Jewel (IntelliJ IDEA) styles. - * - * This is a wrapper around the core multiplatform-markdown-renderer library - * that provides IntelliJ-native theme colors and typography using Jewel. - * - * @param content The markdown content to be rendered. - * @param modifier The [Modifier] to apply to the component. - * @param colors The [MarkdownColors] to use for styling. Defaults to Jewel colors. - * @param typography The [MarkdownTypography] to use for text styles. Defaults to Jewel typography. - */ -@Composable -fun JewelMarkdown( - content: String, - modifier: Modifier = Modifier.fillMaxSize(), - colors: MarkdownColors = jewelMarkdownColor(), - typography: MarkdownTypography = jewelMarkdownTypography(), - padding: MarkdownPadding = markdownPadding(), - dimens: MarkdownDimens = markdownDimens(), - flavour: MarkdownFlavourDescriptor = GFMFlavourDescriptor(), - parser: MarkdownParser = MarkdownParser(flavour), - imageTransformer: ImageTransformer = NoOpImageTransformerImpl(), - annotator: MarkdownAnnotator = markdownAnnotator(), - extendedSpans: MarkdownExtendedSpans = markdownExtendedSpans(), - inlineContent: MarkdownInlineContent = markdownInlineContent(), - components: MarkdownComponents = markdownComponents(), - animations: MarkdownAnimations = markdownAnimations(), - referenceLinkHandler: ReferenceLinkHandler = ReferenceLinkHandlerImpl(), - loading: @Composable (modifier: Modifier) -> Unit = { Box(modifier) }, - success: @Composable (state: State.Success, components: MarkdownComponents, modifier: Modifier) -> Unit = { state, comps, mod -> - MarkdownSuccess(state = state, components = comps, modifier = mod) - }, - error: @Composable (modifier: Modifier) -> Unit = { Box(modifier) }, -) = Markdown( - content = content, - colors = colors, - typography = typography, - modifier = modifier, - padding = padding, - dimens = dimens, - flavour = flavour, - parser = parser, - imageTransformer = imageTransformer, - annotator = annotator, - extendedSpans = extendedSpans, - inlineContent = inlineContent, - components = components, - animations = animations, - referenceLinkHandler = referenceLinkHandler, - loading = loading, - success = success, - error = error, -) - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt deleted file mode 100644 index 1b597c112f..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownColors.kt +++ /dev/null @@ -1,27 +0,0 @@ -package cc.unitmesh.devins.idea.renderer.markdown - -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import com.mikepenz.markdown.model.DefaultMarkdownColors -import com.mikepenz.markdown.model.MarkdownColors -import org.jetbrains.jewel.foundation.theme.JewelTheme - -/** - * Creates a [MarkdownColors] instance using Jewel theme colors. - * Provides IntelliJ-native color scheme for markdown rendering. - */ -@Composable -fun jewelMarkdownColor( - text: Color = JewelTheme.globalColors.text.normal, - codeBackground: Color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), - inlineCodeBackground: Color = codeBackground, - dividerColor: Color = JewelTheme.globalColors.borders.normal, - tableBackground: Color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), -): MarkdownColors = DefaultMarkdownColors( - text = text, - codeBackground = codeBackground, - inlineCodeBackground = inlineCodeBackground, - dividerColor = dividerColor, - tableBackground = tableBackground, -) - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt deleted file mode 100644 index d136290312..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownTypography.kt +++ /dev/null @@ -1,68 +0,0 @@ -package cc.unitmesh.devins.idea.renderer.markdown - -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.TextStyle -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.sp -import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import com.mikepenz.markdown.model.DefaultMarkdownTypography -import com.mikepenz.markdown.model.MarkdownTypography -import org.jetbrains.jewel.foundation.theme.JewelTheme - -/** - * Creates a [MarkdownTypography] instance using Jewel theme typography. - * Provides IntelliJ-native typography styles for markdown rendering. - */ -@Composable -fun jewelMarkdownTypography( - baseStyle: TextStyle = JewelTheme.defaultTextStyle, - h1: TextStyle = baseStyle.copy(fontSize = 28.sp, fontWeight = FontWeight.Bold), - h2: TextStyle = baseStyle.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold), - h3: TextStyle = baseStyle.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold), - h4: TextStyle = baseStyle.copy(fontSize = 18.sp, fontWeight = FontWeight.SemiBold), - h5: TextStyle = baseStyle.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold), - h6: TextStyle = baseStyle.copy(fontSize = 14.sp, fontWeight = FontWeight.SemiBold), - text: TextStyle = baseStyle.copy(fontSize = 14.sp), - code: TextStyle = baseStyle.copy(fontFamily = FontFamily.Monospace, fontSize = 13.sp), - inlineCode: TextStyle = text.copy(fontFamily = FontFamily.Monospace), - quote: TextStyle = baseStyle.copy( - fontStyle = FontStyle.Italic, - fontSize = 14.sp, - color = JewelTheme.globalColors.text.info - ), - paragraph: TextStyle = text, - ordered: TextStyle = text, - bullet: TextStyle = text, - list: TextStyle = text, - textLink: TextLinkStyles = TextLinkStyles( - style = SpanStyle( - color = AutoDevColors.Blue.c400, - fontWeight = FontWeight.Medium, - textDecoration = TextDecoration.Underline - ) - ), - table: TextStyle = text, -): MarkdownTypography = DefaultMarkdownTypography( - h1 = h1, - h2 = h2, - h3 = h3, - h4 = h4, - h5 = h5, - h6 = h6, - text = text, - quote = quote, - code = code, - inlineCode = inlineCode, - paragraph = paragraph, - ordered = ordered, - bullet = bullet, - list = list, - textLink = textLink, - table = table, -) - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt new file mode 100644 index 0000000000..a8aa92795d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -0,0 +1,220 @@ +package cc.unitmesh.devins.idea.renderer.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +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.Modifier +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.unit.dp +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.GFMFlavourDescriptor +import org.intellij.markdown.parser.MarkdownParser +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +/** + * Simple Jewel-themed Markdown renderer using JetBrains' intellij-markdown parser. + * This avoids the version mismatch issues with mikepenz library. + */ +@Composable +fun SimpleJewelMarkdown( + content: String, + modifier: Modifier = Modifier.fillMaxWidth() +) { + 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) + } +} + +@Composable +private fun RenderNode(node: ASTNode, content: String) { + when (node.type) { + MarkdownElementTypes.MARKDOWN_FILE -> { + node.children.forEach { child -> + RenderNode(node = child, content = content) + } + } + MarkdownElementTypes.PARAGRAPH -> { + val text = node.getTextInNode(content).toString().trim() + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), + modifier = Modifier.padding(vertical = 2.dp) + ) + } + MarkdownElementTypes.ATX_1 -> { + val text = extractHeaderText(node, content) + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.padding(vertical = 6.dp) + ) + } + MarkdownElementTypes.ATX_2 -> { + val text = extractHeaderText(node, content) + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.padding(vertical = 5.dp) + ) + } + MarkdownElementTypes.ATX_3, MarkdownElementTypes.ATX_4, + MarkdownElementTypes.ATX_5, MarkdownElementTypes.ATX_6 -> { + val text = extractHeaderText(node, content) + Text( + text = text, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ), + modifier = Modifier.padding(vertical = 4.dp) + ) + } + MarkdownElementTypes.CODE_FENCE -> { + val codeText = extractCodeFenceContent(node, content) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + .horizontalScroll(rememberScrollState()) + ) { + Text( + text = codeText, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + ) + } + } + MarkdownElementTypes.CODE_BLOCK -> { + val codeText = node.getTextInNode(content).toString() + Box( + modifier = Modifier + .fillMaxWidth() + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Text( + text = codeText, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + ) + } + } + MarkdownElementTypes.BLOCK_QUOTE -> { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Box( + modifier = Modifier + .width(3.dp) + .height(IntrinsicSize.Min) + .background(AutoDevColors.Blue.c400) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + node.children.forEach { child -> + RenderNode(node = child, content = content) + } + } + } + } + MarkdownElementTypes.UNORDERED_LIST -> { + Column(modifier = Modifier.padding(start = 12.dp)) { + node.children.forEach { child -> + if (child.type == MarkdownElementTypes.LIST_ITEM) { + Row { + Text("• ", style = JewelTheme.defaultTextStyle) + Column(modifier = Modifier.weight(1f)) { + child.children.forEach { itemChild -> + RenderNode(node = itemChild, content = content) + } + } + } + } + } + } + } + MarkdownElementTypes.ORDERED_LIST -> { + Column(modifier = Modifier.padding(start = 12.dp)) { + var index = 1 + node.children.forEach { child -> + if (child.type == MarkdownElementTypes.LIST_ITEM) { + Row { + Text("${index++}. ", style = JewelTheme.defaultTextStyle) + Column(modifier = Modifier.weight(1f)) { + child.children.forEach { itemChild -> + RenderNode(node = itemChild, content = content) + } + } + } + } + } + } + } + else -> { + // For other node types, try to render children or show raw text + if (node.children.isNotEmpty()) { + node.children.forEach { child -> + RenderNode(node = child, content = content) + } + } + } + } +} + +/** + * Extract header text, removing the # prefix + */ +private fun extractHeaderText(node: ASTNode, content: String): String { + val fullText = node.getTextInNode(content).toString() + return fullText.trimStart('#').trim() +} + +/** + * Extract code fence content, removing the ``` markers and language identifier + */ +private fun extractCodeFenceContent(node: ASTNode, content: String): String { + val lines = node.getTextInNode(content).toString().lines() + if (lines.size <= 2) return "" + // Remove first line (``` + language) and last line (```) + return lines.drop(1).dropLast(1).joinToString("\n") +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index 8d41a66ff4..db517348d2 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -6,9 +6,7 @@ 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.JewelMarkdown -import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownColor -import cc.unitmesh.devins.idea.renderer.markdown.jewelMarkdownTypography +import cc.unitmesh.devins.idea.renderer.markdown.SimpleJewelMarkdown import cc.unitmesh.devins.parser.CodeFence import com.intellij.openapi.Disposable import org.jetbrains.jewel.ui.component.CircularProgressIndicator @@ -47,11 +45,9 @@ object IdeaSketchRenderer { when (fence.languageId.lowercase()) { "markdown", "md", "" -> { if (fence.text.isNotBlank()) { - JewelMarkdown( + SimpleJewelMarkdown( content = fence.text, - modifier = Modifier.fillMaxWidth(), - colors = jewelMarkdownColor(), - typography = jewelMarkdownTypography() + modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 829b3f4513..2720883ad0 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -925,5 +925,335 @@ object IdeaComposeIcons { }.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() + } + } 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 006475312c..6327b1982a 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,26 +1,37 @@ 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.foundation.verticalScroll 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.codereview.ModifiedCodeRange import cc.unitmesh.agent.diff.ChangeType +import cc.unitmesh.agent.diff.DiffLineType +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.AIAnalysisProgress -import cc.unitmesh.devins.ui.compose.agent.codereview.AnalysisStage -import cc.unitmesh.devins.ui.compose.agent.codereview.CommitInfo -import cc.unitmesh.devins.ui.compose.agent.codereview.DiffFileInfo +import cc.unitmesh.devins.idea.toolwindow.components.IdeaResizableSplitPane +import cc.unitmesh.devins.ui.compose.agent.codereview.* import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.Disposable import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -30,6 +41,11 @@ import org.jetbrains.jewel.ui.component.* /** * 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( @@ -38,41 +54,55 @@ fun IdeaCodeReviewContent( ) { 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.selectCommit(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() }, - parentDisposable = parentDisposable, - modifier = Modifier.width(350.dp).fillMaxHeight() - ) - } + 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() + ) + }, + second = { + // Center + Right: Diff view and AI analysis + IdeaResizableSplitPane( + modifier = Modifier.fillMaxSize(), + 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) + } + DiffViewerPanel( + diffFiles = state.diffFiles, + selectedCommits = selectedCommits, + 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() + ) + } + ) + } + ) } @Composable @@ -180,255 +210,1700 @@ private fun CommitItem( } } +/** + * File view mode for diff display + */ +private enum class IdeaFileViewMode { + LIST, // Flat list of files + TREE // Tree structure grouped by directory +} + +/** + * Redesigned DiffViewerPanel matching DiffCenterView from mpp-ui. + * Features: + * - Commit info card with issue display + * - File view mode toggle (list/tree) + * - Expandable file list with diff hunks + * - Issue loading/error states with refresh + */ @Composable private fun DiffViewerPanel( diffFiles: List, - selectedFileIndex: Int, - isLoading: Boolean, - onFileSelect: (Int) -> Unit, + selectedCommits: List, + isLoadingDiff: Boolean, + onViewFile: ((String) -> Unit)? = null, + onRefreshIssue: ((Int) -> Unit)? = null, + onConfigureToken: () -> 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 - } + var viewMode by remember { mutableStateOf(IdeaFileViewMode.LIST) } - 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) - ) - } - } + Column( + modifier = modifier + .fillMaxSize() + .background(JewelTheme.globalColors.panelBackground) + .padding(8.dp) + ) { + // Header with commit info and issue info + if (selectedCommits.isNotEmpty()) { + IdeaCommitInfoCard( + selectedCommits = selectedCommits, + onRefreshIssue = onRefreshIssue, + onConfigureToken = onConfigureToken + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Files header with view mode toggle + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Files changed (${diffFiles.size})", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 13.sp + ) + ) + + // View mode toggle + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = { viewMode = IdeaFileViewMode.LIST }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.List, + contentDescription = "List view", + tint = if (viewMode == IdeaFileViewMode.LIST) + AutoDevColors.Indigo.c600 + else + JewelTheme.globalColors.text.info, + modifier = Modifier.size(16.dp) + ) + } + IconButton( + onClick = { viewMode = IdeaFileViewMode.TREE }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.AccountTree, + contentDescription = "Tree view", + tint = if (viewMode == IdeaFileViewMode.TREE) + AutoDevColors.Indigo.c600 + else + JewelTheme.globalColors.text.info, + modifier = Modifier.size(16.dp) + ) } } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) } - // Diff content - if (isLoading) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Content area + if (isLoadingDiff) { + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text( + text = "Loading diff...", + style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info) + ) + } } } else if (diffFiles.isEmpty()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center + ) { Text( - text = "Select a commit to view diff", + 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 { - val selectedFile = diffFiles.getOrNull(selectedFileIndex) - if (selectedFile != null) { - DiffContent(file = selectedFile) + // File list based on view mode + when (viewMode) { + IdeaFileViewMode.LIST -> { + IdeaCompactFileListView( + files = diffFiles, + onViewFile = onViewFile + ) + } + IdeaFileViewMode.TREE -> { + IdeaFileTreeView( + files = diffFiles, + onViewFile = onViewFile + ) + } } } } } +/** + * Commit info card with issue display + */ @Composable -private fun DiffContent(file: DiffFileInfo) { - val scrollState = rememberScrollState() - - Column( +private fun IdeaCommitInfoCard( + selectedCommits: List, + onRefreshIssue: ((Int) -> Unit)?, + onConfigureToken: () -> Unit +) { + Box( 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 + .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() + // Single commit view + 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.height(8.dp)) + Spacer(modifier = Modifier.width(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 - ) - ) + // Inline issue indicator + IdeaIssueIndicator( + commit = selectedCommit, + commitIndex = 0, + onRefreshIssue = onRefreshIssue, + onConfigureToken = onConfigureToken + ) + } - Spacer(modifier = Modifier.height(4.dp)) + 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) + ) + ) + } - // 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 + // Expanded issue information (if available) + selectedCommit.issueInfo?.let { issueInfo -> + Spacer(modifier = Modifier.height(8.dp)) + IdeaIssueInfoCard(issueInfo = issueInfo) } + } else { + // Multiple commits view + 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 = diffLine.content, + text = "Range: ${oldest.shortHash}..${newest.shortHash}", style = JewelTheme.defaultTextStyle.copy( fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - color = color + fontSize = 12.sp, + color = JewelTheme.globalColors.text.info ) ) - } - Spacer(modifier = Modifier.height(8.dp)) + 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 -private fun AIAnalysisPanel( - progress: AIAnalysisProgress, - error: String?, - onStartAnalysis: () -> Unit, - onCancelAnalysis: () -> Unit, - parentDisposable: Disposable, - modifier: Modifier = Modifier +private fun IdeaIssueIndicator( + commit: CommitInfo, + commitIndex: Int, + onRefreshIssue: ((Int) -> Unit)?, + onConfigureToken: () -> Unit ) { - 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 { + commit.isLoadingIssue -> { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } + commit.issueInfo != null -> { + val issueInfo = commit.issueInfo!! + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IdeaInlineIssueChip(issueInfo = issueInfo) + + // Show cache indicator and refresh button if from cache + 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) + ) + ) + } + + // Refresh button + if (onRefreshIssue != null) { + IconButton( + onClick = { onRefreshIssue(commitIndex) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Refresh, + contentDescription = "Refresh issue", + tint = JewelTheme.globalColors.text.info.copy(alpha = 0.6f), + modifier = Modifier.size(14.dp) + ) + } + } + } + } + commit.issueLoadError != null -> { + val errorMessage = commit.issueLoadError!! + 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) + ) ) - ) - when (progress.stage) { - AnalysisStage.IDLE, AnalysisStage.COMPLETED, AnalysisStage.ERROR -> { - DefaultButton(onClick = onStartAnalysis) { - Text("Start Analysis") + // Retry button + if (onRefreshIssue != null) { + IconButton( + onClick = { onRefreshIssue(commitIndex) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Refresh, + contentDescription = "Retry", + tint = AutoDevColors.Red.c400.copy(alpha = 0.8f), + modifier = Modifier.size(14.dp) + ) } } - else -> { - OutlinedButton(onClick = onCancelAnalysis) { - Text("Cancel") + + // Configure token button (only for auth errors) + 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 = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Settings, + contentDescription = "Configure", + modifier = Modifier.size(12.dp) + ) + Text( + text = "Token", + style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp) + ) + } } } } } + } +} - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Status +/** + * Inline compact issue chip + */ +@Composable +private fun IdeaInlineIssueChip(issueInfo: cc.unitmesh.agent.tracker.IssueInfo) { + Box( + modifier = Modifier + .background( + when (issueInfo.status.lowercase()) { + "open" -> AutoDevColors.Green.c600.copy(alpha = 0.15f) + "closed" -> AutoDevColors.Neutral.c600.copy(alpha = 0.15f) + else -> AutoDevColors.Indigo.c600.copy(alpha = 0.15f) + }, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - val (statusText, statusColor) = when (progress.stage) { - AnalysisStage.IDLE -> "Ready" to JewelTheme.globalColors.text.info - AnalysisStage.RUNNING_LINT -> "Running lint..." to AutoDevColors.Amber.c400 - AnalysisStage.ANALYZING_LINT -> "Analyzing code..." to AutoDevColors.Blue.c400 - AnalysisStage.GENERATING_PLAN -> "Generating plan..." to AutoDevColors.Blue.c400 - AnalysisStage.WAITING_FOR_USER_INPUT -> "Waiting for input..." to AutoDevColors.Amber.c400 - AnalysisStage.GENERATING_FIX -> "Generating fixes..." to AutoDevColors.Blue.c400 - AnalysisStage.COMPLETED -> "Completed" to AutoDevColors.Green.c400 - AnalysisStage.ERROR -> "Error" to AutoDevColors.Red.c400 - } - - if (progress.stage != AnalysisStage.IDLE && - progress.stage != AnalysisStage.COMPLETED && - progress.stage != AnalysisStage.ERROR) { - CircularProgressIndicator() - } - + Icon( + imageVector = when (issueInfo.status.lowercase()) { + "open" -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.BugReport + "closed" -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.CheckCircle + else -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Info + }, + contentDescription = issueInfo.status, + tint = when (issueInfo.status.lowercase()) { + "open" -> AutoDevColors.Green.c600 + "closed" -> AutoDevColors.Neutral.c600 + else -> AutoDevColors.Indigo.c600 + }, + modifier = Modifier.size(14.dp) + ) Text( - text = statusText, - style = JewelTheme.defaultTextStyle.copy(color = statusColor) + text = "#${issueInfo.id}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = when (issueInfo.status.lowercase()) { + "open" -> AutoDevColors.Green.c600 + "closed" -> AutoDevColors.Neutral.c600 + else -> AutoDevColors.Indigo.c600 + } + ) ) } + } +} + +/** + * Issue info card with full details + */ +@Composable +private 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) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.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 + ) + ) + } + + // Status badge + Box( + modifier = Modifier + .background( + when (issueInfo.status.lowercase()) { + "open" -> AutoDevColors.Green.c600.copy(alpha = 0.2f) + "closed" -> AutoDevColors.Red.c600.copy(alpha = 0.2f) + else -> JewelTheme.globalColors.panelBackground + }, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = issueInfo.status, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = when (issueInfo.status.lowercase()) { + "open" -> AutoDevColors.Green.c600 + "closed" -> AutoDevColors.Red.c600 + else -> JewelTheme.globalColors.text.info + } + ) + ) + } + } - // Error message - if (error != null) { Text( - text = error, + text = issueInfo.title, style = JewelTheme.defaultTextStyle.copy( - color = AutoDevColors.Red.c400, - fontSize = 12.sp + fontSize = 12.sp, + fontWeight = FontWeight.Medium ), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + maxLines = 2 ) - } - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Analysis output - use IdeaSketchRenderer for rich markdown/code rendering - val scrollState = rememberScrollState() - Box( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(12.dp) - ) { - if (progress.analysisOutput.isNotEmpty()) { - val isComplete = progress.stage == AnalysisStage.COMPLETED || - progress.stage == AnalysisStage.ERROR - IdeaSketchRenderer.RenderResponse( - content = progress.analysisOutput, - isComplete = isComplete, - parentDisposable = parentDisposable, - modifier = Modifier.fillMaxWidth() - ) - } else { + if (issueInfo.description.isNotBlank()) { Text( - text = "Click 'Start Analysis' to begin AI code review", + text = issueInfo.description, style = JewelTheme.defaultTextStyle.copy( - color = JewelTheme.globalColors.text.info, - fontSize = 12.sp - ) + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info + ), + maxLines = 3 ) } + + // Labels + if (issueInfo.labels.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) + ) { + issueInfo.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 (issueInfo.labels.size > 5) { + Text( + text = "+${issueInfo.labels.size - 5}", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } + } + } + } +} + +/** + * Compact file list view with expandable diff items + */ +@Composable +private fun IdeaCompactFileListView( + files: List, + onViewFile: ((String) -> Unit)? +) { + val scrollState = rememberLazyListState() + var expandedFileIndex by remember { mutableStateOf(null) } + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(files) { index, file -> + IdeaCompactFileDiffItem( + file = file, + isExpanded = expandedFileIndex == index, + onToggleExpand = { + expandedFileIndex = if (expandedFileIndex == index) null else index + }, + onViewFile = onViewFile + ) } } } +/** + * Compact file diff item with expandable hunks + */ +@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 -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Add + ChangeType.DELETE -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Delete + ChangeType.RENAME -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.DriveFileRenameOutline + else -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Edit + } + + Column(modifier = Modifier.fillMaxWidth()) { + // File header row + 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) + ) { + // Expand/collapse icon + Icon( + imageVector = if (isExpanded) + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ExpandMore + else + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ChevronRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(16.dp) + ) + + // Change type icon + Icon( + imageVector = changeIcon, + contentDescription = file.changeType.name, + tint = changeColor, + modifier = Modifier.size(14.dp) + ) + + // File name + Text( + text = file.path.split("/").lastOrNull() ?: file.path, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + ) + + // File path (directory) + val directory = file.path.substringBeforeLast("/", "") + if (directory.isNotEmpty()) { + Text( + text = directory, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) + ) + ) + } + } + + // Actions + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + // View file button + if (onViewFile != null) { + IconButton( + onClick = { onViewFile(file.path) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Visibility, + contentDescription = "View file", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(14.dp) + ) + } + } + + // Line count badge + val addedLines = file.hunks.sumOf { hunk -> + hunk.lines.count { it.type == DiffLineType.ADDED } + } + val deletedLines = file.hunks.sumOf { hunk -> + hunk.lines.count { it.type == DiffLineType.DELETED } + } + + if (addedLines > 0) { + Text( + text = "+$addedLines", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Green.c400, + fontWeight = FontWeight.Bold + ) + ) + } + if (deletedLines > 0) { + Text( + text = "-$deletedLines", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = AutoDevColors.Red.c400, + fontWeight = FontWeight.Bold + ) + ) + } + } + } + + // Expanded diff content + 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 { hunkIndex, hunk -> + if (hunkIndex > 0) { + Spacer(modifier = Modifier.height(8.dp)) + } + IdeaDiffHunkView(hunk = hunk) + } + } + } + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + } +} + +/** + * Diff hunk view with line numbers and content + */ +@Composable +private fun IdeaDiffHunkView(hunk: cc.unitmesh.agent.diff.DiffHunk) { + Column(modifier = Modifier.fillMaxWidth()) { + // 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.c400 + ), + modifier = Modifier.padding(bottom = 4.dp) + ) + + // Lines + hunk.lines.forEach { line -> + IdeaDiffLineView(line = line) + } + } +} + +/** + * Single diff line view + */ +@Composable +private fun IdeaDiffLineView(line: cc.unitmesh.agent.diff.DiffLine) { + val backgroundColor = when (line.type) { + DiffLineType.ADDED -> AutoDevColors.Green.c400.copy(alpha = 0.15f) + DiffLineType.DELETED -> AutoDevColors.Red.c400.copy(alpha = 0.15f) + else -> Color.Transparent + } + + val textColor = when (line.type) { + DiffLineType.ADDED -> AutoDevColors.Green.c400 + DiffLineType.DELETED -> AutoDevColors.Red.c400 + else -> JewelTheme.globalColors.text.normal + } + + val prefix = when (line.type) { + DiffLineType.ADDED -> "+" + DiffLineType.DELETED -> "-" + else -> " " + } + + // Use appropriate line number based on line type + val displayLineNumber = when (line.type) { + DiffLineType.ADDED -> line.newLineNumber + DiffLineType.DELETED -> line.oldLineNumber + else -> line.newLineNumber ?: line.oldLineNumber + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + .padding(horizontal = 4.dp, vertical = 1.dp) + ) { + // Line number + Text( + text = (displayLineNumber ?: 0).toString().padStart(4), + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f) + ), + modifier = Modifier.width(36.dp) + ) + + // Prefix + Text( + text = prefix, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = textColor, + fontWeight = FontWeight.Bold + ), + modifier = Modifier.width(12.dp) + ) + + // Content + Text( + text = line.content.removePrefix(prefix), + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = textColor + ), + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) + } +} + +/** + * File tree view with directory grouping + */ +@Composable +private fun IdeaFileTreeView( + files: List, + onViewFile: ((String) -> Unit)? +) { + val scrollState = rememberLazyListState() + val treeNodes = remember(files) { buildFileTreeStructure(files) } + var expandedDirs by remember { mutableStateOf(setOf()) } + var expandedFileIndex by remember { mutableStateOf(null) } + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize() + ) { + treeNodes.forEach { node -> + when (node) { + is FileTreeNode.Directory -> { + item(key = "dir_${node.path}") { + IdeaDirectoryTreeItem( + directory = node, + isExpanded = expandedDirs.contains(node.path), + onToggle = { + 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 = file, + isExpanded = expandedFileIndex == files.indexOf(file), + onToggleExpand = { + val fileIndex = files.indexOf(file) + expandedFileIndex = if (expandedFileIndex == fileIndex) null else fileIndex + }, + onViewFile = onViewFile, + indentLevel = 1 + ) + } + } + } + } + is FileTreeNode.File -> { + item(key = "file_root_${node.file.path}") { + IdeaFileTreeItemCompact( + file = node.file, + isExpanded = expandedFileIndex == files.indexOf(node.file), + onToggleExpand = { + val fileIndex = files.indexOf(node.file) + expandedFileIndex = if (expandedFileIndex == fileIndex) null else fileIndex + }, + onViewFile = onViewFile, + indentLevel = 0 + ) + } + } + } + } + } +} + +/** + * File tree node sealed class + */ +private sealed class FileTreeNode { + data class Directory( + val name: String, + val path: String, + val files: List + ) : FileTreeNode() + + data class File(val file: DiffFileInfo) : FileTreeNode() +} + +/** + * Build file tree structure from flat file list + */ +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( + name = path.split("/").lastOrNull() ?: path, + path = path, + files = dirFiles.sortedBy { it.path } + )) + } + + return result +} + +/** + * Directory tree item + */ +@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( + imageVector = if (isExpanded) + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ExpandMore + else + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ChevronRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(16.dp) + ) + + Icon( + imageVector = if (isExpanded) + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.FolderOpen + else + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Folder, + contentDescription = "Directory", + tint = AutoDevColors.Amber.c400, + modifier = Modifier.size(16.dp) + ) + + Text( + text = directory.name, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + ) + + Text( + text = "(${directory.files.size})", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) + ) + ) + } +} + +/** + * File tree item (compact version for tree view) + */ +@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( + imageVector = if (isExpanded) + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ExpandMore + else + cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ChevronRight, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(14.dp) + ) + + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Description, + contentDescription = "File", + tint = changeColor, + modifier = Modifier.size(14.dp) + ) + + Text( + text = file.path.split("/").lastOrNull() ?: file.path, + style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) + ) + } + + // View file button + if (onViewFile != null) { + IconButton( + onClick = { onViewFile(file.path) }, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Visibility, + contentDescription = "View file", + tint = JewelTheme.globalColors.text.info, + modifier = Modifier.size(12.dp) + ) + } + } + } + + // Expanded diff content + 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 { hunkIndex, hunk -> + if (hunkIndex > 0) { + Spacer(modifier = Modifier.height(8.dp)) + } + IdeaDiffHunkView(hunk = hunk) + } + } + } + } +} + +/** + * Comprehensive AI Analysis Panel with Plan, User Input, and Fix sections. + * Redesigned to match the CodeReviewAgentPanel from mpp-ui. + */ +@Composable +private fun IdeaAIAnalysisPanel( + state: CodeReviewState, + viewModel: IdeaCodeReviewViewModel, + parentDisposable: Disposable, + modifier: Modifier = Modifier +) { + val progress = state.aiProgress + + Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { + // Header with action button + IdeaAnalysisHeader( + stage = progress.stage, + hasDiffFiles = state.diffFiles.isNotEmpty(), + onStartAnalysis = { viewModel.startAnalysis() }, + onCancelAnalysis = { viewModel.cancelAnalysis() } + ) + + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + // Error message + state.error?.let { error -> + Text( + text = error, + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Red.c400, + fontSize = 12.sp + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + + // Content area with scrollable sections + Box(modifier = Modifier.fillMaxSize().padding(8.dp)) { + if (progress.stage == AnalysisStage.IDLE && progress.lintResults.isEmpty()) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + 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) + ) { + // Lint Analysis Section + if (progress.lintResults.isNotEmpty() || progress.lintOutput.isNotEmpty()) { + item { + IdeaLintAnalysisCard( + lintResults = progress.lintResults, + lintOutput = progress.lintOutput, + isActive = progress.stage == AnalysisStage.RUNNING_LINT, + diffFiles = state.diffFiles, + modifiedCodeRanges = progress.modifiedCodeRanges + ) + } + } + + // AI Analysis Section + if (progress.analysisOutput.isNotEmpty()) { + item { + IdeaAIAnalysisSection( + analysisOutput = progress.analysisOutput, + isActive = progress.stage == AnalysisStage.ANALYZING_LINT, + parentDisposable = parentDisposable + ) + } + } + + // Modification Plan Section + if (progress.planOutput.isNotEmpty()) { + item { + IdeaModificationPlanSection( + planOutput = progress.planOutput, + isActive = progress.stage == AnalysisStage.GENERATING_PLAN, + parentDisposable = parentDisposable, + onItemSelectionChanged = { selection -> + viewModel.setSelectedPlanItems(selection) + } + ) + } + } + + // User Input Section (when waiting for feedback) + if (progress.stage == AnalysisStage.WAITING_FOR_USER_INPUT) { + item { + IdeaUserInputSection( + onGenerate = { feedback -> + viewModel.proceedToGenerateFixes(feedback) + }, + onCancel = { viewModel.cancelAnalysis() } + ) + } + } + + // Fix Generation Section + if (progress.fixRenderer != null || progress.stage == AnalysisStage.GENERATING_FIX) { + item { + IdeaSuggestedFixesSection( + fixOutput = progress.fixOutput, + isGenerating = progress.stage == AnalysisStage.GENERATING_FIX, + parentDisposable = parentDisposable + ) + } + } + } + } + } + } +} + +/** + * Header component with status and action buttons + */ +@Composable +private 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( + text = "AI Code Review", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + + // Status badge + 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( + text = statusText, + style = JewelTheme.defaultTextStyle.copy( + color = statusColor, + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + ) + } + } + } + } + + // Action buttons + when (stage) { + AnalysisStage.IDLE, AnalysisStage.COMPLETED, AnalysisStage.ERROR -> { + DefaultButton( + onClick = onStartAnalysis, + enabled = hasDiffFiles + ) { + Text("Start Review") + } + } + else -> { + OutlinedButton(onClick = onCancelAnalysis) { + Text("Cancel") + } + } + } + } +} + +/** + * Collapsible Lint Analysis Card showing lint results and filtered issues + */ +@Composable +private 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( + title = "Lint Analysis", + isExpanded = isExpanded, + onExpandChange = { isExpanded = it }, + isActive = isActive, + badge = { + if (totalErrors > 0 || totalWarnings > 0) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (totalErrors > 0) { + IdeaBadge(text = "$totalErrors errors", color = AutoDevColors.Red.c400) + } + if (totalWarnings > 0) { + IdeaBadge(text = "$totalWarnings warnings", color = AutoDevColors.Amber.c400) + } + } + } + } + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + lintResults.forEach { result -> + if (result.issues.isNotEmpty()) { + IdeaLintFileCard( + fileResult = result, + modifiedRanges = modifiedCodeRanges[result.filePath] ?: emptyList() + ) + } + } + + if (lintOutput.isNotEmpty() && lintResults.isEmpty()) { + Text( + text = lintOutput, + style = JewelTheme.defaultTextStyle.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.horizontalScroll(rememberScrollState()) + ) + } + } + } +} + +/** + * Card showing lint issues for a single file + */ +@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( + text = fileResult.filePath.substringAfterLast("/"), + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Medium, + fontSize = 12.sp + ) + ) + + fileResult.issues.take(5).forEach { issue -> + IdeaLintIssueRow(issue = issue, modifiedRanges = modifiedRanges) + } + + if (fileResult.issues.size > 5) { + Text( + text = "...and ${fileResult.issues.size - 5} more issues", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info, + fontSize = 11.sp + ) + ) + } + } +} + +/** + * Single lint issue row + */ +@Composable +private fun IdeaLintIssueRow( + issue: LintIssue, + modifiedRanges: List +) { + val isInModifiedRange = modifiedRanges.any { range -> + issue.line in range.startLine..range.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( + 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( + text = issue.message, + style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) + ) + issue.rule?.let { rule -> + Text( + text = rule, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 10.sp, + color = JewelTheme.globalColors.text.info + ) + ) + } + } + } +} + +/** + * AI Analysis Section showing streaming AI analysis output + */ +@Composable +private fun IdeaAIAnalysisSection( + analysisOutput: String, + isActive: Boolean, + parentDisposable: Disposable +) { + var isExpanded by remember { mutableStateOf(true) } + + IdeaCollapsibleCard( + title = "AI Analysis", + isExpanded = isExpanded, + onExpandChange = { isExpanded = it }, + isActive = isActive + ) { + IdeaSketchRenderer.RenderResponse( + content = analysisOutput, + isComplete = !isActive, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * Modification Plan Section showing AI-generated fix plan + */ +@Composable +private fun IdeaModificationPlanSection( + planOutput: String, + isActive: Boolean, + parentDisposable: Disposable, + onItemSelectionChanged: (Set) -> Unit +) { + var isExpanded by remember { mutableStateOf(true) } + + IdeaCollapsibleCard( + title = "Modification Plan", + isExpanded = isExpanded, + onExpandChange = { isExpanded = it }, + isActive = isActive, + badge = { + if (isActive) { + IdeaBadge(text = "Generating...", color = AutoDevColors.Blue.c400) + } + } + ) { + IdeaSketchRenderer.RenderResponse( + content = planOutput, + isComplete = !isActive, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * User Input Section for providing feedback before fix generation + */ +@Composable +private fun IdeaUserInputSection( + onGenerate: (String) -> Unit, + onCancel: () -> Unit +) { + var userInput by remember { mutableStateOf(androidx.compose.ui.text.input.TextFieldValue("")) } + + IdeaCollapsibleCard( + title = "Your Feedback", + isExpanded = true, + onExpandChange = {}, + isActive = true, + badge = { + IdeaBadge(text = "Action Required", color = AutoDevColors.Amber.c400) + } + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + 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") + } + } + } + } +} + +/** + * Suggested Fixes Section showing fix generation output + */ +@Composable +private fun IdeaSuggestedFixesSection( + fixOutput: String, + isGenerating: Boolean, + parentDisposable: Disposable +) { + var isExpanded by remember { mutableStateOf(true) } + + IdeaCollapsibleCard( + title = "Fix Generation", + isExpanded = isExpanded, + onExpandChange = { isExpanded = it }, + isActive = isGenerating, + badge = { + if (isGenerating) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator() + IdeaBadge(text = "Generating...", color = AutoDevColors.Indigo.c400) + } + } else if (fixOutput.isNotEmpty()) { + IdeaBadge(text = "Complete", color = AutoDevColors.Green.c400) + } + } + ) { + if (fixOutput.isNotEmpty()) { + IdeaSketchRenderer.RenderResponse( + content = fixOutput, + isComplete = !isGenerating, + parentDisposable = parentDisposable, + modifier = Modifier.fillMaxWidth() + ) + } else if (isGenerating) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + Text( + text = "No fixes generated yet.", + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.info, + fontSize = 12.sp + ) + ) + } + } +} + +/** + * Reusable collapsible card component for sections + */ +@Composable +private 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)) + ) { + // Header + 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( + text = if (isExpanded) "-" else "+", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ) + ) + + Text( + text = title, + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 13.sp + ) + ) + + badge?.invoke() + } + } + + // Expandable content + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp) + ) { + content() + } + } + } +} + +/** + * Small badge component for status indicators + */ +@Composable +private 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 = 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/IdeaCodeReviewViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCodeReviewViewModel.kt index e4880a2afc..bc1d71cbd0 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 @@ -49,6 +49,20 @@ class IdeaCodeReviewViewModel( } } + /** + * Open a file in the IDE editor + */ + fun openFileViewer(path: String) { + val basePath = project.basePath ?: return + val file = java.io.File(basePath, path) + if (file.exists()) { + val virtualFile = com.intellij.openapi.vfs.LocalFileSystem.getInstance().findFileByIoFile(file) + if (virtualFile != null) { + com.intellij.openapi.fileEditor.FileEditorManager.getInstance(project).openFile(virtualFile, true) + } + } + } + /** * Dispose resources when the ViewModel is no longer needed */ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt new file mode 100644 index 0000000000..c223365b45 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt @@ -0,0 +1,141 @@ +package cc.unitmesh.devins.idea.toolwindow.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/toolwindow/components/IdeaVerticalResizableSplitPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaVerticalResizableSplitPane.kt new file mode 100644 index 0000000000..67f1b98c25 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaVerticalResizableSplitPane.kt @@ -0,0 +1,141 @@ +package cc.unitmesh.devins.idea.toolwindow.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) + } + } +} + From ce9f55070c90183f97b636bbbecc1d6062cd6792 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Sun, 30 Nov 2025 23:55:52 +0800 Subject: [PATCH 24/60] feat(mpp-idea): enhance SimpleJewelMarkdown to full-featured renderer - Add support for inline formatting (bold, italic, strikethrough, code) - Add clickable links with URL annotations - Add GFM table rendering with header/body rows - Add horizontal rule support - Add nested list support with proper indentation - Add GFM checkbox/task list support - Add code fence with language header display - Add block quote with styled left border - Add image alt text display - Fix Composable context issues by passing colors as parameters --- .../renderer/markdown/SimpleJewelMarkdown.kt | 786 +++++++++++++++--- .../codereview/IdeaCodeReviewContent.kt | 24 +- .../codereview/IdeaCodeReviewViewModel.kt | 17 +- 3 files changed, 688 insertions(+), 139 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index a8aa92795d..88c11c899b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -1,209 +1,716 @@ 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.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.foundation.text.ClickableText +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.* 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 org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.ast.findChildOfType 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 /** - * Simple Jewel-themed Markdown renderer using JetBrains' intellij-markdown parser. - * This avoids the version mismatch issues with mikepenz library. + * 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 SimpleJewelMarkdown( content: String, - modifier: Modifier = Modifier.fillMaxWidth() + 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) + RenderNode(node = tree, content = content, onLinkClick = onLinkClick) } } @Composable -private fun RenderNode(node: ASTNode, content: String) { +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) + RenderNode(node = child, content = content, listDepth = listDepth, onLinkClick = onLinkClick) } } MarkdownElementTypes.PARAGRAPH -> { - val text = node.getTextInNode(content).toString().trim() - Text( - text = text, - style = JewelTheme.defaultTextStyle.copy(fontSize = 13.sp), - modifier = Modifier.padding(vertical = 2.dp) - ) + MarkdownParagraph(node = node, content = content, onLinkClick = onLinkClick) } MarkdownElementTypes.ATX_1 -> { - val text = extractHeaderText(node, content) - Text( - text = text, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 22.sp, - fontWeight = FontWeight.Bold - ), - modifier = Modifier.padding(vertical = 6.dp) - ) + MarkdownHeader(node = node, content = content, level = 1) } MarkdownElementTypes.ATX_2 -> { - val text = extractHeaderText(node, content) - Text( - text = text, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ), - modifier = Modifier.padding(vertical = 5.dp) - ) + MarkdownHeader(node = node, content = content, level = 2) } - MarkdownElementTypes.ATX_3, MarkdownElementTypes.ATX_4, - MarkdownElementTypes.ATX_5, MarkdownElementTypes.ATX_6 -> { - val text = extractHeaderText(node, content) - Text( - text = text, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ), - modifier = Modifier.padding(vertical = 4.dp) - ) + 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 -> { - val codeText = extractCodeFenceContent(node, content) - Box( + 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) + } + } + } + } +} + +// ============ Header Component ============ + +@Composable +private fun MarkdownHeader(node: ASTNode, content: String, level: Int) { + val text = 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) + ) +} + +// ============ Paragraph Component with Inline Formatting ============ + +@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) + ) + } +} + +/** + * Build annotated string with inline formatting support + */ +private 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 = 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 -> { + // For reference links, just show the text + appendMarkdownChildren(child, content, codeBackground) + } + MarkdownElementTypes.IMAGE -> { + // Show image alt text in brackets + val altText = child.findChildOfType(MarkdownElementTypes.LINK_TEXT) + ?.getTextInNode(content)?.toString()?.trim('[', ']') ?: "image" + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append("[$altText]") + } + } + else -> { + // Recursively handle other children + if (child.children.isNotEmpty()) { + appendMarkdownChildren(child, content, codeBackground) + } + } + } + } +} + +private fun AnnotatedString.Builder.appendInlineLink(node: ASTNode, content: String) { + val linkText = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) + val linkDest = node.findChildOfType(MarkdownElementTypes.LINK_DESTINATION) + + val text = linkText?.children?.filter { it.type == MarkdownTokenTypes.TEXT } + ?.joinToString("") { it.getTextInNode(content).toString() } + ?: node.getTextInNode(content).toString() + val url = linkDest?.getTextInNode(content)?.toString() ?: "" + + pushStringAnnotation("URL", url) + withStyle(SpanStyle( + color = AutoDevColors.Blue.c400, + textDecoration = TextDecoration.Underline + )) { + append(text) + } + pop() +} + +private fun AnnotatedString.Builder.appendAutoLink(node: ASTNode, content: String) { + val url = node.getTextInNode(content).toString().trim('<', '>') + pushStringAnnotation("URL", url) + withStyle(SpanStyle( + color = AutoDevColors.Blue.c400, + textDecoration = TextDecoration.Underline + )) { + append(url) + } + pop() +} + +private fun extractCodeSpanText(node: ASTNode, content: String): String { + return node.children + .filter { it.type != MarkdownTokenTypes.BACKTICK } + .joinToString("") { it.getTextInNode(content).toString() } + .trim() +} + +// ============ Code Block Components ============ + +@Composable +private fun MarkdownCodeFence(node: ASTNode, content: String) { + val language = node.findChildOfType(MarkdownTokenTypes.FENCE_LANG) + ?.getTextInNode(content)?.toString()?.trim() + val codeText = 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.5f), - RoundedCornerShape(4.dp) - ) - .padding(8.dp) - .horizontalScroll(rememberScrollState()) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = codeText, + text = language, style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = JewelTheme.globalColors.text.info ) ) } - } - MarkdownElementTypes.CODE_BLOCK -> { - val codeText = node.getTextInNode(content).toString() Box( modifier = Modifier .fillMaxWidth() - .background( - JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), - RoundedCornerShape(4.dp) - ) - .padding(8.dp) - ) { - Text( - text = codeText, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 12.sp - ) + .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 + ) + ) + } +} + +// ============ Block Quote Component ============ + +@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) + } } } - MarkdownElementTypes.BLOCK_QUOTE -> { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), - RoundedCornerShape(4.dp) - ) - .padding(8.dp) - ) { - Box( - modifier = Modifier - .width(3.dp) - .height(IntrinsicSize.Min) - .background(AutoDevColors.Blue.c400) + } +} + +// ============ List Components ============ + +@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 ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - node.children.forEach { child -> - RenderNode(node = child, content = content) - } - } } } - MarkdownElementTypes.UNORDERED_LIST -> { - Column(modifier = Modifier.padding(start = 12.dp)) { - node.children.forEach { child -> - if (child.type == MarkdownElementTypes.LIST_ITEM) { - Row { - Text("• ", style = JewelTheme.defaultTextStyle) - Column(modifier = Modifier.weight(1f)) { - child.children.forEach { itemChild -> - RenderNode(node = itemChild, content = content) - } - } - } - } - } + } +} + +@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 + ) } } - MarkdownElementTypes.ORDERED_LIST -> { - Column(modifier = Modifier.padding(start = 12.dp)) { - var index = 1 - node.children.forEach { child -> - if (child.type == MarkdownElementTypes.LIST_ITEM) { - Row { - Text("${index++}. ", style = JewelTheme.defaultTextStyle) - Column(modifier = Modifier.weight(1f)) { - child.children.forEach { itemChild -> - RenderNode(node = itemChild, content = content) - } - } + } +} + +@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) + } } } } - else -> { - // For other node types, try to render children or show raw text - if (node.children.isNotEmpty()) { - node.children.forEach { child -> - RenderNode(node = child, content = content) - } + } +} + +// ============ Horizontal Rule Component ============ + +@Composable +private fun MarkdownHorizontalRule() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal) + ) +} + +// ============ Table Component ============ + +@Composable +private fun MarkdownTable(node: ASTNode, content: String) { + val headerRow = node.children.find { it.type == GFMElementTypes.HEADER } + val bodyRows = node.children.filter { it.type == GFMElementTypes.ROW } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .background( + JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + RoundedCornerShape(6.dp) + ) + .border( + 1.dp, + JewelTheme.globalColors.borders.normal, + RoundedCornerShape(6.dp) + ) + .horizontalScroll(rememberScrollState()) + ) { + // Header row + if (headerRow != null) { + MarkdownTableRow( + node = headerRow, + content = content, + isHeader = true + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal) + ) + } + + // Body rows + bodyRows.forEachIndexed { index, row -> + MarkdownTableRow( + node = row, + content = content, + isHeader = false + ) + if (index < bodyRows.size - 1) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) + ) } } } } +@Composable +private fun MarkdownTableRow( + node: ASTNode, + content: String, + isHeader: Boolean +) { + val cells = node.children.filter { it.type == GFMTokenTypes.CELL } + + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (isHeader) { + Modifier.background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + } else { + Modifier + } + ) + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + cells.forEach { cell -> + val cellText = cell.getTextInNode(content).toString().trim() + Text( + text = cellText, + style = JewelTheme.defaultTextStyle.copy( + fontSize = 12.sp, + fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal + ), + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + } + } +} + +// ============ Helper Functions ============ + /** * Extract header text, removing the # prefix */ private 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() } @@ -212,9 +719,34 @@ private fun extractHeaderText(node: ASTNode, content: String): String { * Extract code fence content, removing the ``` markers and language identifier */ private fun extractCodeFenceContent(node: ASTNode, content: String): String { - val lines = node.getTextInNode(content).toString().lines() - if (lines.size <= 2) return "" - // Remove first line (``` + language) and last line (```) - return lines.drop(1).dropLast(1).joinToString("\n") -} + 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() +} 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 6327b1982a..515e47eb11 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 @@ -84,6 +84,7 @@ fun IdeaCodeReviewContent( DiffViewerPanel( diffFiles = state.diffFiles, selectedCommits = selectedCommits, + selectedCommitIndices = state.selectedCommitIndices, isLoadingDiff = state.isLoadingDiff, onViewFile = { path -> viewModel.openFileViewer(path) }, onRefreshIssue = { index -> viewModel.refreshIssueForCommit(index) }, @@ -230,6 +231,7 @@ private enum class IdeaFileViewMode { private fun DiffViewerPanel( diffFiles: List, selectedCommits: List, + selectedCommitIndices: Set, isLoadingDiff: Boolean, onViewFile: ((String) -> Unit)? = null, onRefreshIssue: ((Int) -> Unit)? = null, @@ -248,6 +250,7 @@ private fun DiffViewerPanel( if (selectedCommits.isNotEmpty()) { IdeaCommitInfoCard( selectedCommits = selectedCommits, + selectedCommitIndices = selectedCommitIndices.toList(), onRefreshIssue = onRefreshIssue, onConfigureToken = onConfigureToken ) @@ -352,10 +355,12 @@ private fun DiffViewerPanel( /** * Commit info card with issue display + * @param selectedCommitIndices The actual indices in the commit history for proper refresh targeting */ @Composable private fun IdeaCommitInfoCard( selectedCommits: List, + selectedCommitIndices: List, onRefreshIssue: ((Int) -> Unit)?, onConfigureToken: () -> Unit ) { @@ -388,10 +393,11 @@ private fun IdeaCommitInfoCard( Spacer(modifier = Modifier.width(8.dp)) - // Inline issue indicator + // Inline issue indicator - use the actual commit index + val actualCommitIndex = selectedCommitIndices.firstOrNull() ?: 0 IdeaIssueIndicator( commit = selectedCommit, - commitIndex = 0, + commitIndex = actualCommitIndex, onRefreshIssue = onRefreshIssue, onConfigureToken = onConfigureToken ) @@ -1027,6 +1033,7 @@ private fun IdeaDiffLineView(line: cc.unitmesh.agent.diff.DiffLine) { /** * File tree view with directory grouping + * Uses file.path as unique identifier for O(1) expansion tracking instead of indexOf */ @Composable private fun IdeaFileTreeView( @@ -1036,7 +1043,8 @@ private fun IdeaFileTreeView( val scrollState = rememberLazyListState() val treeNodes = remember(files) { buildFileTreeStructure(files) } var expandedDirs by remember { mutableStateOf(setOf()) } - var expandedFileIndex by remember { mutableStateOf(null) } + // Use file path as identifier instead of index for O(1) lookup + var expandedFilePath by remember { mutableStateOf(null) } LazyColumn( state = scrollState, @@ -1064,10 +1072,9 @@ private fun IdeaFileTreeView( item(key = "file_${node.path}_$index") { IdeaFileTreeItemCompact( file = file, - isExpanded = expandedFileIndex == files.indexOf(file), + isExpanded = expandedFilePath == file.path, onToggleExpand = { - val fileIndex = files.indexOf(file) - expandedFileIndex = if (expandedFileIndex == fileIndex) null else fileIndex + expandedFilePath = if (expandedFilePath == file.path) null else file.path }, onViewFile = onViewFile, indentLevel = 1 @@ -1080,10 +1087,9 @@ private fun IdeaFileTreeView( item(key = "file_root_${node.file.path}") { IdeaFileTreeItemCompact( file = node.file, - isExpanded = expandedFileIndex == files.indexOf(node.file), + isExpanded = expandedFilePath == node.file.path, onToggleExpand = { - val fileIndex = files.indexOf(node.file) - expandedFileIndex = if (expandedFileIndex == fileIndex) null else fileIndex + expandedFilePath = if (expandedFilePath == node.file.path) null else node.file.path }, onViewFile = onViewFile, indentLevel = 0 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 bc1d71cbd0..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 @@ -53,12 +53,23 @@ class IdeaCodeReviewViewModel( * Open a file in the IDE editor */ fun openFileViewer(path: String) { - val basePath = project.basePath ?: return + val basePath = project.basePath ?: run { + logger.warn("Cannot open file: project basePath is null") + return + } val file = java.io.File(basePath, path) - if (file.exists()) { - val virtualFile = com.intellij.openapi.vfs.LocalFileSystem.getInstance().findFileByIoFile(file) + if (!file.exists()) { + logger.warn("File not found in openFileViewer: ${file.path}") + return + } + + 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}") } } } From f87cffc98476bd4fe5890cbed09dd574ee24ad2f Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 00:07:56 +0800 Subject: [PATCH 25/60] fix(mpp-idea): improve table renderer with adaptive column weights - Parse GFM table structure correctly using HEADER, ROW, and CELL types - Calculate adaptive column weights based on content length - Clean cell text by removing markdown formatting (|, `, **, *) - Add proper vertical alignment for table rows - Reference implementation from mpp-ui/MarkdownTableRenderer.kt --- .../renderer/markdown/SimpleJewelMarkdown.kt | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index 88c11c899b..1efd6e8ebf 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -602,11 +602,48 @@ private fun MarkdownHorizontalRule() { // ============ Table Component ============ +/** + * 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 + */ @Composable private 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 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 = 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 } + } + Column( modifier = Modifier .fillMaxWidth() @@ -627,7 +664,8 @@ private fun MarkdownTable(node: ASTNode, content: String) { MarkdownTableRow( node = headerRow, content = content, - isHeader = true + isHeader = true, + columnWeights = columnWeights ) Box( modifier = Modifier @@ -637,12 +675,13 @@ private fun MarkdownTable(node: ASTNode, content: String) { ) } - // Body rows + // Body rows (skip TABLE_SEPARATOR which is handled implicitly) bodyRows.forEachIndexed { index, row -> MarkdownTableRow( node = row, content = content, - isHeader = false + isHeader = false, + columnWeights = columnWeights ) if (index < bodyRows.size - 1) { Box( @@ -660,10 +699,13 @@ private fun MarkdownTable(node: ASTNode, content: String) { private fun MarkdownTableRow( node: ASTNode, content: String, - isHeader: Boolean + isHeader: Boolean, + columnWeights: List ) { val cells = node.children.filter { it.type == GFMTokenTypes.CELL } + if (cells.isEmpty()) return + Row( modifier = Modifier .fillMaxWidth() @@ -674,10 +716,13 @@ private fun MarkdownTableRow( Modifier } ) - .padding(horizontal = 8.dp, vertical = 6.dp) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically ) { - cells.forEach { cell -> - val cellText = cell.getTextInNode(content).toString().trim() + cells.forEachIndexed { idx, cell -> + val weight = if (idx < columnWeights.size) columnWeights[idx] else 1f / cells.size.coerceAtLeast(1) + val cellText = extractCellText(cell, content) + Text( text = cellText, style = JewelTheme.defaultTextStyle.copy( @@ -685,13 +730,26 @@ private fun MarkdownTableRow( fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal ), modifier = Modifier - .weight(1f) + .weight(weight) .padding(horizontal = 8.dp) ) } } } +/** + * Extract clean text from a table cell node. + * Removes markdown formatting characters like |, `, **, etc. + */ +private fun extractCellText(cell: ASTNode, content: String): String { + return content.substring(cell.startOffset, cell.endOffset) + .replace("|", "") + .replace("`", "") + .replace("**", "") + .replace("*", "") + .trim() +} + // ============ Helper Functions ============ /** From 35de079cb93465db16f7354e97ff6170c78fdaef Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 07:40:45 +0800 Subject: [PATCH 26/60] fix(mpp-idea): fix table cell text extraction - Extract text from TEXT tokens within cells instead of raw substring - Add collectTextFromNode to recursively collect text from AST nodes - Handle WHITE_SPACE tokens as spaces - Fixes table rendering showing one character per line --- .../renderer/markdown/SimpleJewelMarkdown.kt | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index 1efd6e8ebf..040a4c1c2d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -739,15 +739,34 @@ private fun MarkdownTableRow( /** * Extract clean text from a table cell node. - * Removes markdown formatting characters like |, `, **, etc. + * The CELL node contains TEXT children that have the actual cell content. + * We collect all TEXT token content and join them. */ private fun extractCellText(cell: ASTNode, content: String): String { - return content.substring(cell.startOffset, cell.endOffset) - .replace("|", "") - .replace("`", "") - .replace("**", "") - .replace("*", "") - .trim() + // Collect text from TEXT tokens within the cell + val textParts = mutableListOf() + collectTextFromNode(cell, content, textParts) + return textParts.joinToString("").trim() +} + +/** + * Recursively collect text from TEXT tokens in the AST node. + */ +private fun collectTextFromNode(node: ASTNode, content: String, result: MutableList) { + when (node.type) { + MarkdownTokenTypes.TEXT -> { + result.add(node.getTextInNode(content).toString()) + } + MarkdownTokenTypes.WHITE_SPACE -> { + result.add(" ") + } + else -> { + // Recurse into children + node.children.forEach { child -> + collectTextFromNode(child, content, result) + } + } + } } // ============ Helper Functions ============ From 85cc24eda5fe00c0ed1c6000ea7a11f92c0c3c16 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 08:00:26 +0800 Subject: [PATCH 27/60] feat(mpp-idea): sync IdeaKnowledgeContent UI with mpp-ui DocumentReaderPage - Add IdeaStructuredInfoPane component for TOC and entities display - Refactor IdeaKnowledgeContent to use resizable split panes - Use IdeaResizableSplitPane for horizontal layout (left/center/right) - Use IdeaVerticalResizableSplitPane for document content + structured info - Add navigateToEntity method to IdeaKnowledgeViewModel - Remove duplicate TOC from DocumentContentPanel (now in StructuredInfoPane) --- .../renderer/markdown/SimpleJewelMarkdown.kt | 33 +-- .../knowledge/IdeaKnowledgeContent.kt | 159 +++++++------ .../knowledge/IdeaKnowledgeViewModel.kt | 15 ++ .../knowledge/IdeaStructuredInfoPane.kt | 218 ++++++++++++++++++ 4 files changed, 317 insertions(+), 108 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaStructuredInfoPane.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index 040a4c1c2d..1482d4b900 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -739,34 +739,15 @@ private fun MarkdownTableRow( /** * Extract clean text from a table cell node. - * The CELL node contains TEXT children that have the actual cell content. - * We collect all TEXT token content and join them. + * Uses the raw cell text and strips markdown formatting. */ private fun extractCellText(cell: ASTNode, content: String): String { - // Collect text from TEXT tokens within the cell - val textParts = mutableListOf() - collectTextFromNode(cell, content, textParts) - return textParts.joinToString("").trim() -} - -/** - * Recursively collect text from TEXT tokens in the AST node. - */ -private fun collectTextFromNode(node: ASTNode, content: String, result: MutableList) { - when (node.type) { - MarkdownTokenTypes.TEXT -> { - result.add(node.getTextInNode(content).toString()) - } - MarkdownTokenTypes.WHITE_SPACE -> { - result.add(" ") - } - else -> { - // Recurse into children - node.children.forEach { child -> - collectTextFromNode(child, content, result) - } - } - } + return cell.getTextInNode(content).toString() + .replace("|", "") + .replace("`", "") + .replace("**", "") + .replace("*", "") + .trim() } // ============ Helper Functions ============ diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index 4a4b3333ba..d5a4c129d0 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import cc.unitmesh.devins.idea.renderer.JewelRenderer import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.idea.toolwindow.components.IdeaResizableSplitPane +import cc.unitmesh.devins.idea.toolwindow.components.IdeaVerticalResizableSplitPane import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import kotlinx.coroutines.flow.distinctUntilChanged import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -26,10 +28,10 @@ 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: - * - Left: Document list with search - * - Center: Document content viewer - * - Right: AI Chat interface + * 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( @@ -40,47 +42,76 @@ fun IdeaKnowledgeContent( val timeline by viewModel.renderer.timeline.collectAsState() val streamingOutput by viewModel.renderer.currentStreamingOutput.collectAsState() - Row( - modifier = modifier.fillMaxSize() - ) { - // 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.width(250.dp) - ) - - Divider(Orientation.Vertical, modifier = Modifier.fillMaxHeight().width(1.dp)) - - // Center panel: Document content viewer - DocumentContentPanel( - document = state.selectedDocument, - content = state.documentContent ?: state.parsedContent, - isLoading = state.isLoading, - targetLineNumber = state.targetLineNumber, - highlightedText = state.highlightedText, - onTocItemClick = { viewModel.navigateToTocItem(it) }, - modifier = Modifier.weight(1f) - ) - - Divider(Orientation.Vertical, modifier = Modifier.fillMaxHeight().width(1.dp)) - - // 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.width(400.dp) - ) - } + // 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() + ) + } + ) + } + ) } /** @@ -262,7 +293,7 @@ private fun DocumentListItem( } /** - * Document content viewer panel + * Document content viewer panel (TOC moved to IdeaStructuredInfoPane) */ @Composable private fun DocumentContentPanel( @@ -271,7 +302,6 @@ private fun DocumentContentPanel( isLoading: Boolean, targetLineNumber: Int?, highlightedText: String?, - onTocItemClick: (cc.unitmesh.devins.document.TOCItem) -> Unit, modifier: Modifier = Modifier ) { Column( @@ -339,41 +369,6 @@ private fun DocumentContentPanel( Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - // TOC panel if available - if (document.toc.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - Text( - text = "Table of Contents (${document.toc.size})", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - fontSize = 12.sp - ) - ) - Spacer(modifier = Modifier.height(4.dp)) - LazyColumn( - modifier = Modifier.heightIn(max = 150.dp) - ) { - items(document.toc) { tocItem -> - // Guard against zero or negative levels to prevent IllegalArgumentException - val safeLevel = tocItem.level.coerceAtLeast(1) - Text( - text = "${" ".repeat(safeLevel - 1)}• ${tocItem.title}", - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), - modifier = Modifier - .fillMaxWidth() - .clickable { onTocItemClick(tocItem) } - .padding(vertical = 2.dp) - ) - } - } - } - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - } - // Content viewer if (content != null) { val listState = rememberLazyListState() 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 index 5b6af795a4..4ce05dffce 100644 --- 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 @@ -366,6 +366,21 @@ class IdeaKnowledgeViewModel( } } + /** + * 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 */ 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 + ) + } + } + } + } +} + From f140086c3e52dd190b49685d7881b084783d87af Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 08:18:31 +0800 Subject: [PATCH 28/60] feat(markdown): add horizontal scroll for wide tables Enable horizontal scrolling for markdown tables that exceed the viewport width and set a default minimum column width. Add tests for markdown table parsing. --- .../renderer/markdown/SimpleJewelMarkdown.kt | 85 ++++++---- .../markdown/MarkdownTableParserTest.kt | 154 ++++++++++++++++++ 2 files changed, 208 insertions(+), 31 deletions(-) create mode 100644 mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTableParserTest.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index 1482d4b900..03c5bb85e4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -16,8 +16,10 @@ 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.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes @@ -602,6 +604,9 @@ private fun MarkdownHorizontalRule() { // ============ Table Component ============ +/** Default cell width for table columns */ +private val TABLE_CELL_WIDTH = 120.dp + /** * GFM Table renderer following the intellij-markdown AST structure. * Table structure: @@ -611,6 +616,8 @@ private fun MarkdownHorizontalRule() { * - 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 private fun MarkdownTable(node: ASTNode, content: String) { @@ -621,6 +628,9 @@ private fun MarkdownTable(node: ASTNode, content: String) { 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 } @@ -644,9 +654,8 @@ private fun MarkdownTable(node: ASTNode, content: String) { constrained.map { it / constrainedTotal } } - Column( + BoxWithConstraints( modifier = Modifier - .fillMaxWidth() .padding(vertical = 4.dp) .background( JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), @@ -657,39 +666,51 @@ private fun MarkdownTable(node: ASTNode, content: String) { JewelTheme.globalColors.borders.normal, RoundedCornerShape(6.dp) ) - .horizontalScroll(rememberScrollState()) ) { - // Header row - if (headerRow != null) { - MarkdownTableRow( - node = headerRow, - content = content, - isHeader = true, - columnWeights = columnWeights - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(JewelTheme.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 - ) - if (index < bodyRows.size - 1) { + // Determine if scrolling is needed + val scrollable = maxWidth < tableWidth + + Column( + modifier = if (scrollable) { + Modifier.horizontalScroll(rememberScrollState()).requiredWidth(tableWidth) + } else { + Modifier.fillMaxWidth() + } + ) { + // Header row + if (headerRow != null) { + MarkdownTableRow( + node = headerRow, + content = content, + isHeader = true, + columnWeights = columnWeights, + tableWidth = tableWidth + ) Box( modifier = Modifier .fillMaxWidth() .height(1.dp) - .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) + .background(JewelTheme.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 + .fillMaxWidth() + .height(1.dp) + .background(JewelTheme.globalColors.borders.normal.copy(alpha = 0.5f)) + ) + } } } } @@ -700,7 +721,8 @@ private fun MarkdownTableRow( node: ASTNode, content: String, isHeader: Boolean, - columnWeights: List + columnWeights: List, + tableWidth: Dp ) { val cells = node.children.filter { it.type == GFMTokenTypes.CELL } @@ -708,7 +730,8 @@ private fun MarkdownTableRow( Row( modifier = Modifier - .fillMaxWidth() + .widthIn(min = tableWidth) + .height(IntrinsicSize.Max) .then( if (isHeader) { Modifier.background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) 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() + } +} + From bbddbc84030cdb7da40321cf0aa338a38f0585c2 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 00:20:11 +0000 Subject: [PATCH 29/60] feat(mpp-idea): implement remote agent support for IntelliJ IDEA plugin - Add IdeaRemoteAgentClient for HTTP/SSE communication with mpp-server - Add IdeaRemoteAgentViewModel for state management and event handling - Add IdeaRemoteAgentContent UI with server configuration panel - Update IdeaAgentApp to integrate REMOTE agent type - Add comprehensive tests for ViewModel and renderer interactions Referenced from mpp-ui's RemoteAgentChatInterface implementation. --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 70 +++- .../remote/IdeaRemoteAgentClient.kt | 149 ++++++++ .../remote/IdeaRemoteAgentContent.kt | 318 ++++++++++++++++++ .../remote/IdeaRemoteAgentViewModel.kt | 278 +++++++++++++++ .../remote/IdeaRemoteAgentViewModelTest.kt | 309 +++++++++++++++++ 5 files changed, 1121 insertions(+), 3 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt create mode 100644 mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt 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 33a7182d62..ca1e5cbe90 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 @@ -18,6 +18,9 @@ import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel import cc.unitmesh.devins.idea.toolwindow.header.IdeaAgentTabsHeader 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.toolwindow.status.IdeaToolLoadingStatusBar import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent @@ -77,6 +80,13 @@ fun IdeaAgentApp( // 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()) { @@ -87,7 +97,7 @@ 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) @@ -95,6 +105,13 @@ fun IdeaAgentApp( 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 ViewModels when leaving their tabs @@ -108,6 +125,10 @@ fun IdeaAgentApp( knowledgeViewModel?.dispose() knowledgeViewModel = null } + if (currentAgentType != AgentType.REMOTE) { + remoteAgentViewModel?.dispose() + remoteAgentViewModel = null + } } } @@ -133,13 +154,23 @@ fun IdeaAgentApp( .weight(1f) ) { when (currentAgentType) { - AgentType.CODING, AgentType.REMOTE, AgentType.LOCAL_CHAT -> { + AgentType.CODING, AgentType.LOCAL_CHAT -> { IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, listState = listState ) } + AgentType.REMOTE -> { + remoteAgentViewModel?.let { vm -> + IdeaRemoteAgentContent( + viewModel = vm, + listState = listState, + onProjectIdChange = { remoteProjectId = it }, + onGitUrlChange = { remoteGitUrl = it } + ) + } ?: IdeaEmptyStateMessage("Loading Remote Agent...") + } AgentType.CODE_REVIEW -> { codeReviewViewModel?.let { vm -> IdeaCodeReviewContent( @@ -159,7 +190,7 @@ fun IdeaAgentApp( 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) { + if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.LOCAL_CHAT) { IdeaDevInInputArea( project = project, parentDisposable = viewModel, @@ -181,6 +212,39 @@ fun IdeaAgentApp( ) } + // Remote agent input area + if (currentAgentType == AgentType.REMOTE) { + remoteAgentViewModel?.let { remoteVm -> + val remoteIsExecuting by remoteVm.isExecuting.collectAsState() + val remoteIsConnected by remoteVm.isConnected.collectAsState() + + 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) } + ) + } + } + // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, 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..619e817808 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentClient.kt @@ -0,0 +1,149 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import cc.unitmesh.agent.RemoteAgentEvent +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 kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +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 + } + + expectSuccess = false + + engine { + maxConnectionsCount = 1000 + endpoint { + maxConnectionsPerRoute = 100 + pipelineMaxSize = 20 + keepAliveTime = 5000 + connectTimeout = 5000 + connectAttempts = 5 + } + } + } + + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Health check to verify server is running + */ + suspend fun healthCheck(): HealthResponse { + val response = httpClient.get("$baseUrl/health") + return json.decodeFromString(response.bodyAsText()) + } + + /** + * Get list of available projects from server + */ + suspend fun getProjects(): ProjectListResponse { + val response = httpClient.get("$baseUrl/api/projects") + return json.decodeFromString(response.bodyAsText()) + } + + /** + * 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(json.encodeToString(RemoteAgentRequest.serializer(), request)) + } + ) { + 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 + */ +@Serializable +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 +) + +@Serializable +data class LLMConfig( + val provider: String, + val modelName: String, + val apiKey: String, + val baseUrl: String? = null +) + +@Serializable +data class HealthResponse( + val status: String +) + +@Serializable +data class ProjectInfo( + val id: String, + val name: String, + val path: String, + val description: String +) + +@Serializable +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..a804e0f41b --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentContent.kt @@ -0,0 +1,318 @@ +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.toolwindow.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 + ) + } + } +} + +/** + * 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 + */ +fun getEffectiveProjectId(projectId: String, gitUrl: String): String { + return if (gitUrl.isNotBlank()) { + gitUrl.split('/').last().removeSuffix(".git") + } 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..56d895f90b --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModel.kt @@ -0,0 +1,278 @@ +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( + private 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 = gitUrl.split('/').lastOrNull()?.removeSuffix(".git") ?: "temp-project", + task = task, + llmConfig = llmConfig, + gitUrl = gitUrl + ) + } else { + val isGitUrl = projectId.startsWith("http://") || + projectId.startsWith("https://") || + projectId.startsWith("git@") + + if (isGitUrl) { + RemoteAgentRequest( + projectId = projectId.split('/').lastOrNull()?.removeSuffix(".git") ?: "temp-project", + task = task, + llmConfig = llmConfig, + gitUrl = projectId + ) + } else { + RemoteAgentRequest( + projectId = projectId, + task = task, + llmConfig = llmConfig + ) + } + } + } + + /** + * 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/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..74f893cf09 --- /dev/null +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -0,0 +1,309 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +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 JewelRenderer.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 JewelRenderer.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 JewelRenderer.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 JewelRenderer.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 JewelRenderer.TimelineItem.MessageItem) + assertTrue((lastItem as JewelRenderer.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 JewelRenderer.TimelineItem.MessageItem + assertEquals(JewelRenderer.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) + } +} + From 3c16a42b93eda3b4620f01a0b4a549840ba9b8ec Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 08:31:53 +0800 Subject: [PATCH 30/60] refactor(markdown): extract and test markdown parsing utilities Move markdown text extraction and inline rendering logic into reusable utility objects. Add unit tests for MarkdownTextParser to ensure correct parsing of headers, code fences, and code spans. --- .../markdown/MarkdownInlineRenderer.kt | 147 ++++++++++++ .../renderer/markdown/MarkdownTextParser.kt | 136 +++++++++++ .../renderer/markdown/SimpleJewelMarkdown.kt | 221 +----------------- .../markdown/MarkdownTextParserTest.kt | 171 ++++++++++++++ 4 files changed, 460 insertions(+), 215 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownInlineRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParser.kt create mode 100644 mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTextParserTest.kt 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/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/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index 03c5bb85e4..6d4f9e8c41 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -10,16 +10,14 @@ import androidx.compose.foundation.text.ClickableText 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.* 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.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.times +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 @@ -142,7 +140,7 @@ private fun RenderNode( @Composable private fun MarkdownHeader(node: ASTNode, content: String, level: Int) { - val text = extractHeaderText(node, content) + val text = MarkdownTextParser.extractHeaderText(node, content) val (fontSize, fontWeight) = when (level) { 1 -> 24.sp to FontWeight.Bold 2 -> 20.sp to FontWeight.Bold @@ -212,147 +210,12 @@ private fun MarkdownParagraph( } } -/** - * Build annotated string with inline formatting support - */ -private 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 = 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 -> { - // For reference links, just show the text - appendMarkdownChildren(child, content, codeBackground) - } - MarkdownElementTypes.IMAGE -> { - // Show image alt text in brackets - val altText = child.findChildOfType(MarkdownElementTypes.LINK_TEXT) - ?.getTextInNode(content)?.toString()?.trim('[', ']') ?: "image" - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append("[$altText]") - } - } - else -> { - // Recursively handle other children - if (child.children.isNotEmpty()) { - appendMarkdownChildren(child, content, codeBackground) - } - } - } - } -} - -private fun AnnotatedString.Builder.appendInlineLink(node: ASTNode, content: String) { - val linkText = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) - val linkDest = node.findChildOfType(MarkdownElementTypes.LINK_DESTINATION) - - val text = linkText?.children?.filter { it.type == MarkdownTokenTypes.TEXT } - ?.joinToString("") { it.getTextInNode(content).toString() } - ?: node.getTextInNode(content).toString() - val url = linkDest?.getTextInNode(content)?.toString() ?: "" - - pushStringAnnotation("URL", url) - withStyle(SpanStyle( - color = AutoDevColors.Blue.c400, - textDecoration = TextDecoration.Underline - )) { - append(text) - } - pop() -} - -private fun AnnotatedString.Builder.appendAutoLink(node: ASTNode, content: String) { - val url = node.getTextInNode(content).toString().trim('<', '>') - pushStringAnnotation("URL", url) - withStyle(SpanStyle( - color = AutoDevColors.Blue.c400, - textDecoration = TextDecoration.Underline - )) { - append(url) - } - pop() -} - -private fun extractCodeSpanText(node: ASTNode, content: String): String { - return node.children - .filter { it.type != MarkdownTokenTypes.BACKTICK } - .joinToString("") { it.getTextInNode(content).toString() } - .trim() -} - // ============ Code Block Components ============ @Composable private fun MarkdownCodeFence(node: ASTNode, content: String) { - val language = node.findChildOfType(MarkdownTokenTypes.FENCE_LANG) - ?.getTextInNode(content)?.toString()?.trim() - val codeText = extractCodeFenceContent(node, content) + val language = MarkdownTextParser.extractCodeFenceLanguage(node, content) + val codeText = MarkdownTextParser.extractCodeFenceContent(node, content) Column( modifier = Modifier @@ -641,7 +504,7 @@ private fun MarkdownTable(node: ASTNode, content: String) { val cells = rowNode.children.filter { it.type == GFMTokenTypes.CELL } cells.forEachIndexed { idx, cell -> if (idx < columnsCount) { - val raw = extractCellText(cell, content) + val raw = MarkdownTextParser.extractCellText(cell, content) if (raw.length > lengths[idx]) lengths[idx] = raw.length } } @@ -744,7 +607,7 @@ private fun MarkdownTableRow( ) { cells.forEachIndexed { idx, cell -> val weight = if (idx < columnWeights.size) columnWeights[idx] else 1f / cells.size.coerceAtLeast(1) - val cellText = extractCellText(cell, content) + val cellText = MarkdownTextParser.extractCellText(cell, content) Text( text = cellText, @@ -759,75 +622,3 @@ private fun MarkdownTableRow( } } } - -/** - * Extract clean text from a table cell node. - * Uses the raw cell text and strips markdown formatting. - */ -private fun extractCellText(cell: ASTNode, content: String): String { - return cell.getTextInNode(content).toString() - .replace("|", "") - .replace("`", "") - .replace("**", "") - .replace("*", "") - .trim() -} - -// ============ Helper Functions ============ - -/** - * Extract header text, removing the # prefix - */ -private 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 - */ -private 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() -} 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 + } +} + From 2218c97cbc258e86c7e35bf0f38de9eea179ec6d Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 00:38:10 +0000 Subject: [PATCH 31/60] fix: address PR review comments - Handle trailing slashes in Git URLs when extracting project ID - Add HTTP response status validation in healthCheck() and getProjects() - Add comment explaining expectSuccess = false for SSE connections - Add comprehensive tests for getEffectiveProjectId edge cases --- .../remote/IdeaRemoteAgentClient.kt | 8 +++ .../remote/IdeaRemoteAgentContent.kt | 10 +++- .../remote/IdeaRemoteAgentViewModel.kt | 15 +++++- .../remote/IdeaRemoteAgentViewModelTest.kt | 52 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) 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 index 619e817808..552de4dccb 100644 --- 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 @@ -29,6 +29,8 @@ class IdeaRemoteAgentClient( maxReconnectionAttempts = 3 } + // We handle HTTP errors manually to provide better error messages + // SSE connections need explicit status checking expectSuccess = false engine { @@ -53,6 +55,9 @@ class IdeaRemoteAgentClient( */ suspend fun healthCheck(): HealthResponse { val response = httpClient.get("$baseUrl/health") + if (!response.status.isSuccess()) { + throw RemoteAgentException("Health check failed: ${response.status}") + } return json.decodeFromString(response.bodyAsText()) } @@ -61,6 +66,9 @@ class IdeaRemoteAgentClient( */ suspend fun getProjects(): ProjectListResponse { val response = httpClient.get("$baseUrl/api/projects") + if (!response.status.isSuccess()) { + throw RemoteAgentException("Failed to fetch projects: ${response.status}") + } return json.decodeFromString(response.bodyAsText()) } 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 index a804e0f41b..deb8d42caf 100644 --- 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 @@ -306,11 +306,17 @@ private fun ConnectionStatusBar( } /** - * Get the project ID or Git URL for task execution + * 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.split('/').last().removeSuffix(".git") + 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 index 56d895f90b..8f8a2d2e3c 100644 --- 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 @@ -153,7 +153,7 @@ class IdeaRemoteAgentViewModel( ): RemoteAgentRequest { return if (gitUrl.isNotBlank()) { RemoteAgentRequest( - projectId = gitUrl.split('/').lastOrNull()?.removeSuffix(".git") ?: "temp-project", + projectId = extractProjectIdFromUrl(gitUrl) ?: "temp-project", task = task, llmConfig = llmConfig, gitUrl = gitUrl @@ -165,7 +165,7 @@ class IdeaRemoteAgentViewModel( if (isGitUrl) { RemoteAgentRequest( - projectId = projectId.split('/').lastOrNull()?.removeSuffix(".git") ?: "temp-project", + projectId = extractProjectIdFromUrl(projectId) ?: "temp-project", task = task, llmConfig = llmConfig, gitUrl = projectId @@ -180,6 +180,17 @@ class IdeaRemoteAgentViewModel( } } + /** + * 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 */ diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt index 74f893cf09..a007121073 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -305,5 +305,57 @@ class IdeaRemoteAgentViewModelTest { 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) + } } From e8d68f6adb755bcaf65ad8618c7c4305c34d4c7d Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 00:54:40 +0000 Subject: [PATCH 32/60] fix: address additional PR review comments - Add HTTP status validation in executeStream before processing SSE events - Add comprehensive tests for data classes and state management - Test RemoteAgentRequest with all fields and default values - Test LLMConfig with null baseUrl - Test ProjectListResponse with empty and multiple projects - Test RemoteAgentException with and without cause - Test getEffectiveProjectId with SSH URLs and deep paths --- .../remote/IdeaRemoteAgentClient.kt | 5 + .../remote/IdeaRemoteAgentViewModelTest.kt | 116 ++++++++++++++++++ 2 files changed, 121 insertions(+) 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 index 552de4dccb..8e2149b0c4 100644 --- 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 @@ -86,6 +86,11 @@ class IdeaRemoteAgentClient( setBody(json.encodeToString(RemoteAgentRequest.serializer(), 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 -> diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt index a007121073..0340e0598e 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -357,5 +357,121 @@ class IdeaRemoteAgentViewModelTest { 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) + } } From e435d1cbb362ea9b6951f6aa9d0d61b04cf46fd3 Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 00:57:58 +0000 Subject: [PATCH 33/60] feat(mpp-idea): add IdeaDevInBlockRenderer for devin block support Add missing devin block renderer to IdeaSketchRenderer to achieve feature parity with mpp-ui SketchRenderer. Changes: - Create IdeaDevInBlockRenderer.kt with Jewel theming - Parses devin blocks using ToolCallParser - Renders tool calls in expandable card format - Shows tool name, icon, and truncated parameters - Supports expand/collapse for full JSON parameter view - Falls back to IdeaCodeBlockRenderer for incomplete content - Update IdeaSketchRenderer.kt to route 'devin' blocks to new renderer - Update doc comment to reflect new DevIn support This ensures IdeaSketchRenderer supports all block types that SketchRenderer in mpp-ui supports: - markdown, md, empty -> SimpleJewelMarkdown - diff, patch -> IdeaDiffRenderer - thinking -> IdeaThinkingBlockRenderer - walkthrough -> IdeaWalkthroughBlockRenderer - mermaid, mmd -> MermaidDiagramView - devin -> IdeaDevInBlockRenderer (NEW) - else -> IdeaCodeBlockRenderer --- .../renderer/sketch/IdeaDevInBlockRenderer.kt | 263 ++++++++++++++++++ .../renderer/sketch/IdeaSketchRenderer.kt | 12 + 2 files changed, 275 insertions(+) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt new file mode 100644 index 0000000000..4ba0e4fe7d --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt @@ -0,0 +1,263 @@ +package cc.unitmesh.devins.idea.renderer.sketch + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.parser.ToolCallParser +import cc.unitmesh.agent.tool.ToolType +import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons +import cc.unitmesh.devins.ui.compose.theme.AutoDevColors +import cc.unitmesh.devins.workspace.WorkspaceManager +import kotlinx.serialization.json.Json +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.Text + +/** + * DevIn Block Renderer for IntelliJ IDEA with Jewel styling. + * + * Parses devin blocks (language id = "devin") and renders them as tool call items + * when the block is complete. Similar to DevInBlockRenderer in mpp-ui but using + * Jewel theming. + */ +@Composable +fun IdeaDevInBlockRenderer( + devinContent: String, + isComplete: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + if (isComplete) { + // Parse the devin block to extract tool calls + val parser = remember { ToolCallParser() } + val wrappedContent = "\n$devinContent\n" + val toolCalls = remember(devinContent) { parser.parseToolCalls(wrappedContent) } + + if (toolCalls.isNotEmpty()) { + // Get workspace root path for resolving relative paths + val workspaceRoot = WorkspaceManager.currentWorkspace?.rootPath + + toolCalls.forEach { toolCall -> + val toolName = toolCall.toolName + val params = toolCall.params + + // Format details string (for display) + val details = formatToolCallDetails(params) + + // Resolve relative path to absolute path using workspace root + val relativePath = params["path"] as? String + val filePath = resolveAbsolutePath(relativePath, workspaceRoot) + + // Map tool name to ToolType + val toolType = ToolType.fromName(toolName) + + IdeaDevInToolItem( + toolName = toolName, + details = details, + filePath = filePath, + toolType = toolType, + params = params, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } else { + // If no tool calls found, render as code block + IdeaCodeBlockRenderer( + code = devinContent, + language = "devin", + modifier = Modifier.fillMaxWidth() + ) + } + } else { + // If not complete, show as code block (streaming) + IdeaCodeBlockRenderer( + code = devinContent, + language = "devin", + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +/** + * Tool item display for DevIn block parsing results. + * Shows tool name, type icon, and parameters in a compact expandable format. + */ +@Composable +private fun IdeaDevInToolItem( + toolName: String, + details: String, + filePath: String?, + toolType: ToolType?, + params: Map, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + val hasParams = params.isNotEmpty() + + Box( + modifier = modifier + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Column { + // Header row: Tool icon + Tool name + Details + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { if (hasParams) expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Tool type icon + Icon( + imageVector = IdeaComposeIcons.Build, + contentDescription = "Tool", + modifier = Modifier.size(16.dp), + tint = AutoDevColors.Blue.c400 + ) + + // Tool name + Text( + text = toolName, + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + ) + + // Details (truncated parameters) + if (details.isNotEmpty() && !expanded) { + Text( + text = details.take(60) + if (details.length > 60) "..." else "", + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + color = JewelTheme.globalColors.text.info.copy(alpha = 0.7f) + ), + modifier = Modifier.weight(1f), + maxLines = 1 + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + // Expand/collapse icon + if (hasParams) { + Icon( + imageVector = if (expanded) IdeaComposeIcons.ExpandLess else IdeaComposeIcons.ExpandMore, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier.size(16.dp), + tint = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + } + } + + // Expanded parameters section + if (expanded && hasParams) { + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp) + ) { + Text( + text = formatParamsAsJson(params), + style = JewelTheme.defaultTextStyle.copy( + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + ) + } + } + } + } +} + +/** + * Resolve relative path to absolute path using workspace root + */ +private fun resolveAbsolutePath(relativePath: String?, workspaceRoot: String?): String? { + if (relativePath == null) return null + if (workspaceRoot == null) return relativePath + + // If already an absolute path, return as-is + if (relativePath.startsWith("/") || relativePath.matches(Regex("^[A-Za-z]:.*"))) { + return relativePath + } + + // Combine workspace root with relative path + val separator = if (workspaceRoot.endsWith("/") || workspaceRoot.endsWith("\\")) "" else "/" + return "$workspaceRoot$separator$relativePath" +} + +/** + * Format tool call parameters as a human-readable details string + */ +private fun formatToolCallDetails(params: Map): String { + return params.entries.joinToString(", ") { (key, value) -> + "$key=${truncateValue(value)}" + } +} + +/** + * Truncate long values for display + */ +private fun truncateValue(value: String, maxLength: Int = 100): String { + return if (value.length > maxLength) { + value.take(maxLength) + "..." + } else { + value + } +} + +/** + * Format parameters as JSON string for full display + */ +private fun formatParamsAsJson(params: Map): String { + return try { + Json { + prettyPrint = true + }.encodeToString( + kotlinx.serialization.serializer(), + params + ) + } catch (e: Exception) { + // Fallback to manual formatting + buildString { + appendLine("{") + params.entries.forEachIndexed { index, (key, value) -> + append(" \"$key\": ") + append("\"${value.replace("\"", "\\\"")}\"") + if (index < params.size - 1) { + appendLine(",") + } else { + appendLine() + } + } + append("}") + } + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt index db517348d2..209768341d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -22,6 +22,7 @@ import org.jetbrains.jewel.ui.component.CircularProgressIndicator * - Thinking -> IdeaThinkingBlockRenderer * - Walkthrough -> IdeaWalkthroughBlockRenderer * - Mermaid -> MermaidDiagramView + * - DevIn -> IdeaDevInBlockRenderer */ object IdeaSketchRenderer { @@ -98,6 +99,17 @@ object IdeaSketchRenderer { } } + "devin" -> { + if (fence.text.isNotBlank()) { + IdeaDevInBlockRenderer( + devinContent = fence.text, + isComplete = blockIsComplete, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + else -> { if (fence.text.isNotBlank()) { IdeaCodeBlockRenderer( From e69d23e9b201e0aa601d30d5c20a3ddace63e7ba Mon Sep 17 00:00:00 2001 From: Fengda Huang Date: Mon, 1 Dec 2025 01:21:13 +0000 Subject: [PATCH 34/60] fix(mpp-idea): address review comments for IdeaDevInBlockRenderer Address review feedback from Augment Code and CodeRabbit: 1. Remove unused parameters from IdeaDevInToolItem: - Removed filePath and toolType parameters that were passed but not used - Removed associated computation (resolveAbsolutePath, ToolType.fromName) - Removed unused imports (ToolType, WorkspaceManager, Paths) 2. Hoist Json configuration to reusable instance: - Added PrettyJson val at module level to avoid repeated allocations - Updated formatParamsAsJson to use the hoisted instance 3. Fix JSON escaping in fallback formatter: - Added escapeJsonString helper function - Properly escape backslashes, quotes, newlines, carriage returns, and tabs - This prevents invalid JSON in the expanded parameters view --- .../renderer/sketch/IdeaDevInBlockRenderer.kt | 60 +++++++------------ 1 file changed, 21 insertions(+), 39 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt index 4ba0e4fe7d..f0739b5348 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaDevInBlockRenderer.kt @@ -13,15 +13,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cc.unitmesh.agent.parser.ToolCallParser -import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import cc.unitmesh.devins.workspace.WorkspaceManager import kotlinx.serialization.json.Json import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Icon import org.jetbrains.jewel.ui.component.Text +/** + * Reusable Json instance with pretty print configuration + */ +private val PrettyJson = Json { prettyPrint = true } + /** * DevIn Block Renderer for IntelliJ IDEA with Jewel styling. * @@ -43,9 +46,6 @@ fun IdeaDevInBlockRenderer( val toolCalls = remember(devinContent) { parser.parseToolCalls(wrappedContent) } if (toolCalls.isNotEmpty()) { - // Get workspace root path for resolving relative paths - val workspaceRoot = WorkspaceManager.currentWorkspace?.rootPath - toolCalls.forEach { toolCall -> val toolName = toolCall.toolName val params = toolCall.params @@ -53,18 +53,9 @@ fun IdeaDevInBlockRenderer( // Format details string (for display) val details = formatToolCallDetails(params) - // Resolve relative path to absolute path using workspace root - val relativePath = params["path"] as? String - val filePath = resolveAbsolutePath(relativePath, workspaceRoot) - - // Map tool name to ToolType - val toolType = ToolType.fromName(toolName) - IdeaDevInToolItem( toolName = toolName, details = details, - filePath = filePath, - toolType = toolType, params = params, modifier = Modifier.fillMaxWidth() ) @@ -98,8 +89,6 @@ fun IdeaDevInBlockRenderer( private fun IdeaDevInToolItem( toolName: String, details: String, - filePath: String?, - toolType: ToolType?, params: Map, modifier: Modifier = Modifier ) { @@ -195,23 +184,6 @@ private fun IdeaDevInToolItem( } } -/** - * Resolve relative path to absolute path using workspace root - */ -private fun resolveAbsolutePath(relativePath: String?, workspaceRoot: String?): String? { - if (relativePath == null) return null - if (workspaceRoot == null) return relativePath - - // If already an absolute path, return as-is - if (relativePath.startsWith("/") || relativePath.matches(Regex("^[A-Za-z]:.*"))) { - return relativePath - } - - // Combine workspace root with relative path - val separator = if (workspaceRoot.endsWith("/") || workspaceRoot.endsWith("\\")) "" else "/" - return "$workspaceRoot$separator$relativePath" -} - /** * Format tool call parameters as a human-readable details string */ @@ -237,19 +209,17 @@ private fun truncateValue(value: String, maxLength: Int = 100): String { */ private fun formatParamsAsJson(params: Map): String { return try { - Json { - prettyPrint = true - }.encodeToString( + PrettyJson.encodeToString( kotlinx.serialization.serializer(), params ) } catch (e: Exception) { - // Fallback to manual formatting + // Fallback to manual formatting with proper JSON escaping buildString { appendLine("{") params.entries.forEachIndexed { index, (key, value) -> - append(" \"$key\": ") - append("\"${value.replace("\"", "\\\"")}\"") + append(" \"${escapeJsonString(key)}\": ") + append("\"${escapeJsonString(value)}\"") if (index < params.size - 1) { appendLine(",") } else { @@ -261,3 +231,15 @@ private fun formatParamsAsJson(params: Map): String { } } +/** + * Escape special characters for valid JSON string + */ +private fun escapeJsonString(value: String): String { + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") +} + From 24e71d18945908481b072b220911e48f7d504890 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 09:44:56 +0800 Subject: [PATCH 35/60] fix(ui): correct yesterday date calculation in sidebar Use DatePeriod to handle month and year boundaries when computing yesterday's date. --- .../cc/unitmesh/devins/ui/compose/chat/SessionSidebar.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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" From 05c8640e26ab5b220511744651524ef17c5a04bb Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 09:55:12 +0800 Subject: [PATCH 36/60] refactor(mpp-idea): move toolwindow components to components Relocate reusable UI components from the toolwindow package to a shared components package for better organization and reusability. Update imports accordingly. --- .../{toolwindow => }/components/IdeaResizableSplitPane.kt | 2 +- .../components/IdeaVerticalResizableSplitPane.kt | 2 +- .../header/IdeaAgentTabsHeader.kt | 2 +- .../status/IdeaToolLoadingStatusBar.kt | 2 +- .../timeline/IdeaErrorBubble.kt | 2 +- .../timeline/IdeaMessageBubble.kt | 2 +- .../timeline/IdeaTaskCompleteBubble.kt | 2 +- .../timeline/IdeaTerminalOutputBubble.kt | 2 +- .../timeline/IdeaTimelineContent.kt | 2 +- .../timeline/IdeaToolCallBubble.kt | 2 +- .../cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt | 8 ++++---- .../idea/toolwindow/codereview/IdeaCodeReviewContent.kt | 3 +-- .../idea/toolwindow/knowledge/IdeaKnowledgeContent.kt | 4 ++-- .../idea/toolwindow/remote/IdeaRemoteAgentContent.kt | 2 +- 14 files changed, 18 insertions(+), 19 deletions(-) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => }/components/IdeaResizableSplitPane.kt (99%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => }/components/IdeaVerticalResizableSplitPane.kt (99%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/header/IdeaAgentTabsHeader.kt (99%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/status/IdeaToolLoadingStatusBar.kt (98%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/timeline/IdeaErrorBubble.kt (97%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/timeline/IdeaMessageBubble.kt (97%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/timeline/IdeaTaskCompleteBubble.kt (97%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/timeline/IdeaTerminalOutputBubble.kt (99%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/timeline/IdeaTimelineContent.kt (98%) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/{toolwindow => components}/timeline/IdeaToolCallBubble.kt (99%) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaResizableSplitPane.kt similarity index 99% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaResizableSplitPane.kt index c223365b45..5fcc8cec0d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaResizableSplitPane.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaResizableSplitPane.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.components +package cc.unitmesh.devins.idea.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaVerticalResizableSplitPane.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaVerticalResizableSplitPane.kt similarity index 99% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaVerticalResizableSplitPane.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaVerticalResizableSplitPane.kt index 67f1b98c25..5bd61539aa 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/components/IdeaVerticalResizableSplitPane.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/IdeaVerticalResizableSplitPane.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.components +package cc.unitmesh.devins.idea.components import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt similarity index 99% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt index cb9c33e3a1..a822ebc50e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/header/IdeaAgentTabsHeader.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/header/IdeaAgentTabsHeader.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.header +package cc.unitmesh.devins.idea.components.header import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt similarity index 98% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt index c448fa1327..1c27e4a70d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/status/IdeaToolLoadingStatusBar.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/status/IdeaToolLoadingStatusBar.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.status +package cc.unitmesh.devins.idea.components.status import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaErrorBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaErrorBubble.kt similarity index 97% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaErrorBubble.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaErrorBubble.kt index 03c94e1e15..e4eb1fdec4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaErrorBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaErrorBubble.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.timeline +package cc.unitmesh.devins.idea.components.timeline import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaMessageBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt similarity index 97% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaMessageBubble.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt index 9ce4a771e5..c9ebc1e066 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaMessageBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.timeline +package cc.unitmesh.devins.idea.components.timeline import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt similarity index 97% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt index 02e3e7ce34..f739cf2481 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTaskCompleteBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.timeline +package cc.unitmesh.devins.idea.components.timeline import androidx.compose.foundation.background import androidx.compose.foundation.layout.* diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt similarity index 99% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt index 4a46ba9d05..331f0f19f9 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.timeline +package cc.unitmesh.devins.idea.components.timeline import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt similarity index 98% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index 4d9f9a4c1c..7ea4769a0d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.timeline +package cc.unitmesh.devins.idea.components.timeline import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt similarity index 99% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt index fefe011586..45cb389413 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/timeline/IdeaToolCallBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt @@ -1,4 +1,4 @@ -package cc.unitmesh.devins.idea.toolwindow.timeline +package cc.unitmesh.devins.idea.components.timeline import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically 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 ca1e5cbe90..0afa6d1cb3 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 @@ -15,15 +15,15 @@ import cc.unitmesh.devins.idea.editor.IdeaInputTrigger 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.idea.toolwindow.header.IdeaAgentTabsHeader +import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader 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.toolwindow.status.IdeaToolLoadingStatusBar -import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaEmptyStateMessage -import cc.unitmesh.devins.idea.toolwindow.timeline.IdeaTimelineContent +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 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 515e47eb11..dc80077b87 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 @@ -14,7 +14,6 @@ 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.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,7 +29,7 @@ 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.idea.toolwindow.components.IdeaResizableSplitPane +import cc.unitmesh.devins.idea.components.IdeaResizableSplitPane import cc.unitmesh.devins.ui.compose.agent.codereview.* import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.Disposable diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index d5a4c129d0..425fef0f8b 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -16,8 +16,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import cc.unitmesh.devins.idea.renderer.JewelRenderer import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons -import cc.unitmesh.devins.idea.toolwindow.components.IdeaResizableSplitPane -import cc.unitmesh.devins.idea.toolwindow.components.IdeaVerticalResizableSplitPane +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 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 index deb8d42caf..c6dbd68ad4 100644 --- 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 @@ -13,7 +13,7 @@ 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.toolwindow.timeline.IdeaTimelineContent +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 From 0e389d418c0d815f5a0eea54c9ea2cee969f5d8c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 12:56:34 +0800 Subject: [PATCH 37/60] refactor(markdown): rename SimpleJewelMarkdown and extract table Rename SimpleJewelMarkdown to JewelMarkdownRenderer and move table rendering logic to a new MarkdownTable.kt file for better modularity. --- .../idea/renderer/IdeaMarkdownRenderer.kt | 6 +- .../idea/renderer/markdown/MarkdownTable.kt | 187 ++++++++++++++++++ .../renderer/markdown/SimpleJewelMarkdown.kt | 172 +--------------- .../renderer/sketch/IdeaSketchRenderer.kt | 4 +- 4 files changed, 193 insertions(+), 176 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/MarkdownTable.kt 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 index 17d8b58e18..ecf6830023 100644 --- 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 @@ -8,7 +8,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cc.unitmesh.devins.idea.renderer.markdown.SimpleJewelMarkdown +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 @@ -46,7 +46,7 @@ fun IdeaMarkdownRenderer( ) } else { // Use simple Jewel Markdown renderer - SimpleJewelMarkdown( + JewelMarkdownRenderer( content = content, modifier = modifier ) @@ -80,7 +80,7 @@ private fun MermaidAwareMarkdownRenderer( } "markdown", "md", "" -> { if (fence.text.isNotBlank()) { - SimpleJewelMarkdown( + JewelMarkdownRenderer( content = fence.text, modifier = Modifier.fillMaxWidth() ) 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/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt index 6d4f9e8c41..a8e7f4f545 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt @@ -13,16 +13,13 @@ 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.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.unit.times 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.findChildOfType import org.intellij.markdown.ast.getTextInNode import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor @@ -47,7 +44,7 @@ import java.net.URI * - Checkboxes (GFM task lists) */ @Composable -fun SimpleJewelMarkdown( +fun JewelMarkdownRenderer( content: String, modifier: Modifier = Modifier.fillMaxWidth(), onLinkClick: ((String) -> Unit)? = null @@ -136,8 +133,6 @@ private fun RenderNode( } } -// ============ Header Component ============ - @Composable private fun MarkdownHeader(node: ASTNode, content: String, level: Int) { val text = MarkdownTextParser.extractHeaderText(node, content) @@ -165,8 +160,6 @@ private fun MarkdownHeader(node: ASTNode, content: String, level: Int) { ) } -// ============ Paragraph Component with Inline Formatting ============ - @Composable private fun MarkdownParagraph( node: ASTNode, @@ -306,8 +299,6 @@ private fun MarkdownCodeBlock(node: ASTNode, content: String) { } } -// ============ Block Quote Component ============ - @Composable private fun MarkdownBlockQuote( node: ASTNode, @@ -341,8 +332,6 @@ private fun MarkdownBlockQuote( } } -// ============ List Components ============ - @Composable private fun MarkdownUnorderedList( node: ASTNode, @@ -452,8 +441,6 @@ private fun MarkdownListItem( } } -// ============ Horizontal Rule Component ============ - @Composable private fun MarkdownHorizontalRule() { Box( @@ -465,160 +452,3 @@ private fun MarkdownHorizontalRule() { ) } -// ============ Table Component ============ - -/** 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 -private 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 - .padding(vertical = 4.dp) - .background( - JewelTheme.globalColors.panelBackground.copy(alpha = 0.3f), - RoundedCornerShape(6.dp) - ) - .border( - 1.dp, - JewelTheme.globalColors.borders.normal, - RoundedCornerShape(6.dp) - ) - ) { - // Determine if scrolling is needed - val scrollable = maxWidth < tableWidth - - Column( - modifier = if (scrollable) { - Modifier.horizontalScroll(rememberScrollState()).requiredWidth(tableWidth) - } else { - Modifier.fillMaxWidth() - } - ) { - // Header row - if (headerRow != null) { - MarkdownTableRow( - node = headerRow, - content = content, - isHeader = true, - columnWeights = columnWeights, - tableWidth = tableWidth - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(JewelTheme.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 - .fillMaxWidth() - .height(1.dp) - .background(JewelTheme.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 - .widthIn(min = tableWidth) - .height(IntrinsicSize.Max) - .then( - if (isHeader) { - Modifier.background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) - } else { - Modifier - } - ) - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.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.defaultTextStyle.copy( - fontSize = 12.sp, - fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal - ), - modifier = Modifier - .weight(weight) - .padding(horizontal = 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 index 209768341d..a30043e1f4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/sketch/IdeaSketchRenderer.kt @@ -6,7 +6,7 @@ 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.SimpleJewelMarkdown +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 @@ -46,7 +46,7 @@ object IdeaSketchRenderer { when (fence.languageId.lowercase()) { "markdown", "md", "" -> { if (fence.text.isNotBlank()) { - SimpleJewelMarkdown( + JewelMarkdownRenderer( content = fence.text, modifier = Modifier.fillMaxWidth() ) From 9a65f2db24ee305e022357fcbd09536270513197 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 13:24:31 +0800 Subject: [PATCH 38/60] refactor(mpp-idea): extract CodeReview components into separate files Split IdeaCodeReviewContent.kt (1915 lines) into 4 focused component files: - IdeaCodeReviewContent.kt (78 lines): Main entry point with three-panel layout - IdeaCommitComponents.kt (569 lines): Commit list and commit info components - IdeaDiffComponents.kt (251 lines): Diff viewer and file tree components - IdeaAnalysisComponents.kt (219 lines): AI analysis and lint result components This improves code organization and maintainability by separating concerns. --- .../codereview/IdeaAnalysisComponents.kt | 218 ++ .../codereview/IdeaCodeReviewContent.kt | 1845 +---------------- .../codereview/IdeaCommitComponents.kt | 571 +++++ .../codereview/IdeaDiffComponents.kt | 251 +++ 4 files changed, 1044 insertions(+), 1841 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaCommitComponents.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaDiffComponents.kt 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..ead7cf10dc --- /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) { viewModel.setSelectedPlanItems(it) } } + } + 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, onItemSelectionChanged: (Set) -> Unit) { + 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 dc80077b87..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,41 +1,12 @@ 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.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.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.codereview.ModifiedCodeRange -import cc.unitmesh.agent.diff.ChangeType -import cc.unitmesh.agent.diff.DiffLineType -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.idea.components.IdeaResizableSplitPane -import cc.unitmesh.devins.ui.compose.agent.codereview.* -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.* /** * Main Code Review content composable for IntelliJ IDEA plugin. @@ -104,1811 +75,3 @@ fun IdeaCodeReviewContent( } ) } - -@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 - ) - ) - } - - 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) } - ) - } - } - } - } -} - -@Composable -private 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 - ) - } -} - -/** - * File view mode for diff display - */ -private enum class IdeaFileViewMode { - LIST, // Flat list of files - TREE // Tree structure grouped by directory -} - -/** - * Redesigned DiffViewerPanel matching DiffCenterView from mpp-ui. - * Features: - * - Commit info card with issue display - * - File view mode toggle (list/tree) - * - Expandable file list with diff hunks - * - Issue loading/error states with refresh - */ -@Composable -private 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) - ) { - // Header with commit info and issue info - if (selectedCommits.isNotEmpty()) { - IdeaCommitInfoCard( - selectedCommits = selectedCommits, - selectedCommitIndices = selectedCommitIndices.toList(), - onRefreshIssue = onRefreshIssue, - onConfigureToken = onConfigureToken - ) - Spacer(modifier = Modifier.height(8.dp)) - } - - // Files header with view mode toggle - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Files changed (${diffFiles.size})", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Medium, - fontSize = 13.sp - ) - ) - - // View mode toggle - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - onClick = { viewMode = IdeaFileViewMode.LIST }, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.List, - contentDescription = "List view", - tint = if (viewMode == IdeaFileViewMode.LIST) - AutoDevColors.Indigo.c600 - else - JewelTheme.globalColors.text.info, - modifier = Modifier.size(16.dp) - ) - } - IconButton( - onClick = { viewMode = IdeaFileViewMode.TREE }, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.AccountTree, - contentDescription = "Tree view", - tint = if (viewMode == IdeaFileViewMode.TREE) - AutoDevColors.Indigo.c600 - else - JewelTheme.globalColors.text.info, - modifier = Modifier.size(16.dp) - ) - } - } - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Content area - if (isLoadingDiff) { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator() - Text( - text = "Loading diff...", - style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info) - ) - } - } - } else if (diffFiles.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize().padding(32.dp), - contentAlignment = Alignment.Center - ) { - Text( - 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 { - // File list based on view mode - when (viewMode) { - IdeaFileViewMode.LIST -> { - IdeaCompactFileListView( - files = diffFiles, - onViewFile = onViewFile - ) - } - IdeaFileViewMode.TREE -> { - IdeaFileTreeView( - files = diffFiles, - onViewFile = onViewFile - ) - } - } - } - } -} - -/** - * Commit info card with issue display - * @param selectedCommitIndices The actual indices in the commit history for proper refresh targeting - */ -@Composable -private 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() - // Single commit view - 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)) - - // Inline issue indicator - use the actual commit index - val actualCommitIndex = selectedCommitIndices.firstOrNull() ?: 0 - 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) - ) - ) - } - - // Expanded issue information (if available) - selectedCommit.issueInfo?.let { issueInfo -> - Spacer(modifier = Modifier.height(8.dp)) - IdeaIssueInfoCard(issueInfo = issueInfo) - } - } else { - // Multiple commits view - 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 -private fun IdeaIssueIndicator( - commit: CommitInfo, - commitIndex: Int, - onRefreshIssue: ((Int) -> Unit)?, - onConfigureToken: () -> Unit -) { - when { - commit.isLoadingIssue -> { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - } - commit.issueInfo != null -> { - val issueInfo = commit.issueInfo!! - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IdeaInlineIssueChip(issueInfo = issueInfo) - - // Show cache indicator and refresh button if from cache - 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) - ) - ) - } - - // Refresh button - if (onRefreshIssue != null) { - IconButton( - onClick = { onRefreshIssue(commitIndex) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Refresh, - contentDescription = "Refresh issue", - tint = JewelTheme.globalColors.text.info.copy(alpha = 0.6f), - modifier = Modifier.size(14.dp) - ) - } - } - } - } - commit.issueLoadError != null -> { - val errorMessage = commit.issueLoadError!! - 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) - ) - ) - - // Retry button - if (onRefreshIssue != null) { - IconButton( - onClick = { onRefreshIssue(commitIndex) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Refresh, - contentDescription = "Retry", - tint = AutoDevColors.Red.c400.copy(alpha = 0.8f), - modifier = Modifier.size(14.dp) - ) - } - } - - // Configure token button (only for auth errors) - 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 = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Settings, - contentDescription = "Configure", - modifier = Modifier.size(12.dp) - ) - Text( - text = "Token", - style = JewelTheme.defaultTextStyle.copy(fontSize = 10.sp) - ) - } - } - } - } - } - } -} - -/** - * Inline compact issue chip - */ -@Composable -private fun IdeaInlineIssueChip(issueInfo: cc.unitmesh.agent.tracker.IssueInfo) { - Box( - modifier = Modifier - .background( - when (issueInfo.status.lowercase()) { - "open" -> AutoDevColors.Green.c600.copy(alpha = 0.15f) - "closed" -> AutoDevColors.Neutral.c600.copy(alpha = 0.15f) - else -> AutoDevColors.Indigo.c600.copy(alpha = 0.15f) - }, - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = when (issueInfo.status.lowercase()) { - "open" -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.BugReport - "closed" -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.CheckCircle - else -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Info - }, - contentDescription = issueInfo.status, - tint = when (issueInfo.status.lowercase()) { - "open" -> AutoDevColors.Green.c600 - "closed" -> AutoDevColors.Neutral.c600 - else -> AutoDevColors.Indigo.c600 - }, - modifier = Modifier.size(14.dp) - ) - Text( - text = "#${issueInfo.id}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - color = when (issueInfo.status.lowercase()) { - "open" -> AutoDevColors.Green.c600 - "closed" -> AutoDevColors.Neutral.c600 - else -> AutoDevColors.Indigo.c600 - } - ) - ) - } - } -} - -/** - * Issue info card with full details - */ -@Composable -private 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) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.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 - ) - ) - } - - // Status badge - Box( - modifier = Modifier - .background( - when (issueInfo.status.lowercase()) { - "open" -> AutoDevColors.Green.c600.copy(alpha = 0.2f) - "closed" -> AutoDevColors.Red.c600.copy(alpha = 0.2f) - else -> JewelTheme.globalColors.panelBackground - }, - RoundedCornerShape(4.dp) - ) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text( - text = issueInfo.status, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 10.sp, - color = when (issueInfo.status.lowercase()) { - "open" -> AutoDevColors.Green.c600 - "closed" -> AutoDevColors.Red.c600 - else -> JewelTheme.globalColors.text.info - } - ) - ) - } - } - - 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 - ) - } - - // Labels - if (issueInfo.labels.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()) - ) { - issueInfo.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 (issueInfo.labels.size > 5) { - Text( - text = "+${issueInfo.labels.size - 5}", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 10.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } - } - } - } - } -} - -/** - * Compact file list view with expandable diff items - */ -@Composable -private fun IdeaCompactFileListView( - files: List, - onViewFile: ((String) -> Unit)? -) { - val scrollState = rememberLazyListState() - var expandedFileIndex by remember { mutableStateOf(null) } - - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxSize() - ) { - itemsIndexed(files) { index, file -> - IdeaCompactFileDiffItem( - file = file, - isExpanded = expandedFileIndex == index, - onToggleExpand = { - expandedFileIndex = if (expandedFileIndex == index) null else index - }, - onViewFile = onViewFile - ) - } - } -} - -/** - * Compact file diff item with expandable hunks - */ -@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 -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Add - ChangeType.DELETE -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Delete - ChangeType.RENAME -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.DriveFileRenameOutline - else -> cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Edit - } - - Column(modifier = Modifier.fillMaxWidth()) { - // File header row - 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) - ) { - // Expand/collapse icon - Icon( - imageVector = if (isExpanded) - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ExpandMore - else - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ChevronRight, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = JewelTheme.globalColors.text.info, - modifier = Modifier.size(16.dp) - ) - - // Change type icon - Icon( - imageVector = changeIcon, - contentDescription = file.changeType.name, - tint = changeColor, - modifier = Modifier.size(14.dp) - ) - - // File name - Text( - text = file.path.split("/").lastOrNull() ?: file.path, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontWeight = FontWeight.Medium - ) - ) - - // File path (directory) - val directory = file.path.substringBeforeLast("/", "") - if (directory.isNotEmpty()) { - Text( - text = directory, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) - ) - ) - } - } - - // Actions - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - // View file button - if (onViewFile != null) { - IconButton( - onClick = { onViewFile(file.path) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Visibility, - contentDescription = "View file", - tint = JewelTheme.globalColors.text.info, - modifier = Modifier.size(14.dp) - ) - } - } - - // Line count badge - val addedLines = file.hunks.sumOf { hunk -> - hunk.lines.count { it.type == DiffLineType.ADDED } - } - val deletedLines = file.hunks.sumOf { hunk -> - hunk.lines.count { it.type == DiffLineType.DELETED } - } - - if (addedLines > 0) { - Text( - text = "+$addedLines", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 10.sp, - color = AutoDevColors.Green.c400, - fontWeight = FontWeight.Bold - ) - ) - } - if (deletedLines > 0) { - Text( - text = "-$deletedLines", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 10.sp, - color = AutoDevColors.Red.c400, - fontWeight = FontWeight.Bold - ) - ) - } - } - } - - // Expanded diff content - 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 { hunkIndex, hunk -> - if (hunkIndex > 0) { - Spacer(modifier = Modifier.height(8.dp)) - } - IdeaDiffHunkView(hunk = hunk) - } - } - } - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - } -} - -/** - * Diff hunk view with line numbers and content - */ -@Composable -private fun IdeaDiffHunkView(hunk: cc.unitmesh.agent.diff.DiffHunk) { - Column(modifier = Modifier.fillMaxWidth()) { - // 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.c400 - ), - modifier = Modifier.padding(bottom = 4.dp) - ) - - // Lines - hunk.lines.forEach { line -> - IdeaDiffLineView(line = line) - } - } -} - -/** - * Single diff line view - */ -@Composable -private fun IdeaDiffLineView(line: cc.unitmesh.agent.diff.DiffLine) { - val backgroundColor = when (line.type) { - DiffLineType.ADDED -> AutoDevColors.Green.c400.copy(alpha = 0.15f) - DiffLineType.DELETED -> AutoDevColors.Red.c400.copy(alpha = 0.15f) - else -> Color.Transparent - } - - val textColor = when (line.type) { - DiffLineType.ADDED -> AutoDevColors.Green.c400 - DiffLineType.DELETED -> AutoDevColors.Red.c400 - else -> JewelTheme.globalColors.text.normal - } - - val prefix = when (line.type) { - DiffLineType.ADDED -> "+" - DiffLineType.DELETED -> "-" - else -> " " - } - - // Use appropriate line number based on line type - val displayLineNumber = when (line.type) { - DiffLineType.ADDED -> line.newLineNumber - DiffLineType.DELETED -> line.oldLineNumber - else -> line.newLineNumber ?: line.oldLineNumber - } - - Row( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor) - .padding(horizontal = 4.dp, vertical = 1.dp) - ) { - // Line number - Text( - text = (displayLineNumber ?: 0).toString().padStart(4), - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = JewelTheme.globalColors.text.info.copy(alpha = 0.5f) - ), - modifier = Modifier.width(36.dp) - ) - - // Prefix - Text( - text = prefix, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = textColor, - fontWeight = FontWeight.Bold - ), - modifier = Modifier.width(12.dp) - ) - - // Content - Text( - text = line.content.removePrefix(prefix), - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 10.sp, - color = textColor - ), - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) - } -} - -/** - * File tree view with directory grouping - * Uses file.path as unique identifier for O(1) expansion tracking instead of indexOf - */ -@Composable -private fun IdeaFileTreeView( - files: List, - onViewFile: ((String) -> Unit)? -) { - val scrollState = rememberLazyListState() - val treeNodes = remember(files) { buildFileTreeStructure(files) } - var expandedDirs by remember { mutableStateOf(setOf()) } - // Use file path as identifier instead of index for O(1) lookup - var expandedFilePath by remember { mutableStateOf(null) } - - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxSize() - ) { - treeNodes.forEach { node -> - when (node) { - is FileTreeNode.Directory -> { - item(key = "dir_${node.path}") { - IdeaDirectoryTreeItem( - directory = node, - isExpanded = expandedDirs.contains(node.path), - onToggle = { - 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 = file, - isExpanded = expandedFilePath == file.path, - onToggleExpand = { - expandedFilePath = if (expandedFilePath == file.path) null else file.path - }, - onViewFile = onViewFile, - indentLevel = 1 - ) - } - } - } - } - is FileTreeNode.File -> { - item(key = "file_root_${node.file.path}") { - IdeaFileTreeItemCompact( - file = node.file, - isExpanded = expandedFilePath == node.file.path, - onToggleExpand = { - expandedFilePath = if (expandedFilePath == node.file.path) null else node.file.path - }, - onViewFile = onViewFile, - indentLevel = 0 - ) - } - } - } - } - } -} - -/** - * File tree node sealed class - */ -private sealed class FileTreeNode { - data class Directory( - val name: String, - val path: String, - val files: List - ) : FileTreeNode() - - data class File(val file: DiffFileInfo) : FileTreeNode() -} - -/** - * Build file tree structure from flat file list - */ -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( - name = path.split("/").lastOrNull() ?: path, - path = path, - files = dirFiles.sortedBy { it.path } - )) - } - - return result -} - -/** - * Directory tree item - */ -@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( - imageVector = if (isExpanded) - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ExpandMore - else - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ChevronRight, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = JewelTheme.globalColors.text.info, - modifier = Modifier.size(16.dp) - ) - - Icon( - imageVector = if (isExpanded) - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.FolderOpen - else - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Folder, - contentDescription = "Directory", - tint = AutoDevColors.Amber.c400, - modifier = Modifier.size(16.dp) - ) - - Text( - text = directory.name, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontWeight = FontWeight.Medium - ) - ) - - Text( - text = "(${directory.files.size})", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 11.sp, - color = JewelTheme.globalColors.text.info.copy(alpha = 0.6f) - ) - ) - } -} - -/** - * File tree item (compact version for tree view) - */ -@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( - imageVector = if (isExpanded) - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ExpandMore - else - cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.ChevronRight, - contentDescription = if (isExpanded) "Collapse" else "Expand", - tint = JewelTheme.globalColors.text.info, - modifier = Modifier.size(14.dp) - ) - - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Description, - contentDescription = "File", - tint = changeColor, - modifier = Modifier.size(14.dp) - ) - - Text( - text = file.path.split("/").lastOrNull() ?: file.path, - style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) - ) - } - - // View file button - if (onViewFile != null) { - IconButton( - onClick = { onViewFile(file.path) }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons.Visibility, - contentDescription = "View file", - tint = JewelTheme.globalColors.text.info, - modifier = Modifier.size(12.dp) - ) - } - } - } - - // Expanded diff content - 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 { hunkIndex, hunk -> - if (hunkIndex > 0) { - Spacer(modifier = Modifier.height(8.dp)) - } - IdeaDiffHunkView(hunk = hunk) - } - } - } - } -} - -/** - * Comprehensive AI Analysis Panel with Plan, User Input, and Fix sections. - * Redesigned to match the CodeReviewAgentPanel from mpp-ui. - */ -@Composable -private fun IdeaAIAnalysisPanel( - state: CodeReviewState, - viewModel: IdeaCodeReviewViewModel, - parentDisposable: Disposable, - modifier: Modifier = Modifier -) { - val progress = state.aiProgress - - Column(modifier = modifier.background(JewelTheme.globalColors.panelBackground)) { - // Header with action button - IdeaAnalysisHeader( - stage = progress.stage, - hasDiffFiles = state.diffFiles.isNotEmpty(), - onStartAnalysis = { viewModel.startAnalysis() }, - onCancelAnalysis = { viewModel.cancelAnalysis() } - ) - - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Error message - state.error?.let { error -> - Text( - text = error, - style = JewelTheme.defaultTextStyle.copy( - color = AutoDevColors.Red.c400, - fontSize = 12.sp - ), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - - // Content area with scrollable sections - Box(modifier = Modifier.fillMaxSize().padding(8.dp)) { - if (progress.stage == AnalysisStage.IDLE && progress.lintResults.isEmpty()) { - // Empty state - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - 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) - ) { - // Lint Analysis Section - if (progress.lintResults.isNotEmpty() || progress.lintOutput.isNotEmpty()) { - item { - IdeaLintAnalysisCard( - lintResults = progress.lintResults, - lintOutput = progress.lintOutput, - isActive = progress.stage == AnalysisStage.RUNNING_LINT, - diffFiles = state.diffFiles, - modifiedCodeRanges = progress.modifiedCodeRanges - ) - } - } - - // AI Analysis Section - if (progress.analysisOutput.isNotEmpty()) { - item { - IdeaAIAnalysisSection( - analysisOutput = progress.analysisOutput, - isActive = progress.stage == AnalysisStage.ANALYZING_LINT, - parentDisposable = parentDisposable - ) - } - } - - // Modification Plan Section - if (progress.planOutput.isNotEmpty()) { - item { - IdeaModificationPlanSection( - planOutput = progress.planOutput, - isActive = progress.stage == AnalysisStage.GENERATING_PLAN, - parentDisposable = parentDisposable, - onItemSelectionChanged = { selection -> - viewModel.setSelectedPlanItems(selection) - } - ) - } - } - - // User Input Section (when waiting for feedback) - if (progress.stage == AnalysisStage.WAITING_FOR_USER_INPUT) { - item { - IdeaUserInputSection( - onGenerate = { feedback -> - viewModel.proceedToGenerateFixes(feedback) - }, - onCancel = { viewModel.cancelAnalysis() } - ) - } - } - - // Fix Generation Section - if (progress.fixRenderer != null || progress.stage == AnalysisStage.GENERATING_FIX) { - item { - IdeaSuggestedFixesSection( - fixOutput = progress.fixOutput, - isGenerating = progress.stage == AnalysisStage.GENERATING_FIX, - parentDisposable = parentDisposable - ) - } - } - } - } - } - } -} - -/** - * Header component with status and action buttons - */ -@Composable -private 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( - text = "AI Code Review", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - fontSize = 14.sp - ) - ) - - // Status badge - 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( - text = statusText, - style = JewelTheme.defaultTextStyle.copy( - color = statusColor, - fontSize = 11.sp, - fontWeight = FontWeight.Medium - ) - ) - } - } - } - } - - // Action buttons - when (stage) { - AnalysisStage.IDLE, AnalysisStage.COMPLETED, AnalysisStage.ERROR -> { - DefaultButton( - onClick = onStartAnalysis, - enabled = hasDiffFiles - ) { - Text("Start Review") - } - } - else -> { - OutlinedButton(onClick = onCancelAnalysis) { - Text("Cancel") - } - } - } - } -} - -/** - * Collapsible Lint Analysis Card showing lint results and filtered issues - */ -@Composable -private 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( - title = "Lint Analysis", - isExpanded = isExpanded, - onExpandChange = { isExpanded = it }, - isActive = isActive, - badge = { - if (totalErrors > 0 || totalWarnings > 0) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - if (totalErrors > 0) { - IdeaBadge(text = "$totalErrors errors", color = AutoDevColors.Red.c400) - } - if (totalWarnings > 0) { - IdeaBadge(text = "$totalWarnings warnings", color = AutoDevColors.Amber.c400) - } - } - } - } - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - lintResults.forEach { result -> - if (result.issues.isNotEmpty()) { - IdeaLintFileCard( - fileResult = result, - modifiedRanges = modifiedCodeRanges[result.filePath] ?: emptyList() - ) - } - } - - if (lintOutput.isNotEmpty() && lintResults.isEmpty()) { - Text( - text = lintOutput, - style = JewelTheme.defaultTextStyle.copy( - fontFamily = FontFamily.Monospace, - fontSize = 11.sp - ), - modifier = Modifier.horizontalScroll(rememberScrollState()) - ) - } - } - } -} - -/** - * Card showing lint issues for a single file - */ -@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( - text = fileResult.filePath.substringAfterLast("/"), - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Medium, - fontSize = 12.sp - ) - ) - - fileResult.issues.take(5).forEach { issue -> - IdeaLintIssueRow(issue = issue, modifiedRanges = modifiedRanges) - } - - if (fileResult.issues.size > 5) { - Text( - text = "...and ${fileResult.issues.size - 5} more issues", - style = JewelTheme.defaultTextStyle.copy( - color = JewelTheme.globalColors.text.info, - fontSize = 11.sp - ) - ) - } - } -} - -/** - * Single lint issue row - */ -@Composable -private fun IdeaLintIssueRow( - issue: LintIssue, - modifiedRanges: List -) { - val isInModifiedRange = modifiedRanges.any { range -> - issue.line in range.startLine..range.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( - 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( - text = issue.message, - style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) - ) - issue.rule?.let { rule -> - Text( - text = rule, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 10.sp, - color = JewelTheme.globalColors.text.info - ) - ) - } - } - } -} - -/** - * AI Analysis Section showing streaming AI analysis output - */ -@Composable -private fun IdeaAIAnalysisSection( - analysisOutput: String, - isActive: Boolean, - parentDisposable: Disposable -) { - var isExpanded by remember { mutableStateOf(true) } - - IdeaCollapsibleCard( - title = "AI Analysis", - isExpanded = isExpanded, - onExpandChange = { isExpanded = it }, - isActive = isActive - ) { - IdeaSketchRenderer.RenderResponse( - content = analysisOutput, - isComplete = !isActive, - parentDisposable = parentDisposable, - modifier = Modifier.fillMaxWidth() - ) - } -} - -/** - * Modification Plan Section showing AI-generated fix plan - */ -@Composable -private fun IdeaModificationPlanSection( - planOutput: String, - isActive: Boolean, - parentDisposable: Disposable, - onItemSelectionChanged: (Set) -> Unit -) { - var isExpanded by remember { mutableStateOf(true) } - - IdeaCollapsibleCard( - title = "Modification Plan", - isExpanded = isExpanded, - onExpandChange = { isExpanded = it }, - isActive = isActive, - badge = { - if (isActive) { - IdeaBadge(text = "Generating...", color = AutoDevColors.Blue.c400) - } - } - ) { - IdeaSketchRenderer.RenderResponse( - content = planOutput, - isComplete = !isActive, - parentDisposable = parentDisposable, - modifier = Modifier.fillMaxWidth() - ) - } -} - -/** - * User Input Section for providing feedback before fix generation - */ -@Composable -private fun IdeaUserInputSection( - onGenerate: (String) -> Unit, - onCancel: () -> Unit -) { - var userInput by remember { mutableStateOf(androidx.compose.ui.text.input.TextFieldValue("")) } - - IdeaCollapsibleCard( - title = "Your Feedback", - isExpanded = true, - onExpandChange = {}, - isActive = true, - badge = { - IdeaBadge(text = "Action Required", color = AutoDevColors.Amber.c400) - } - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - 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") - } - } - } - } -} - -/** - * Suggested Fixes Section showing fix generation output - */ -@Composable -private fun IdeaSuggestedFixesSection( - fixOutput: String, - isGenerating: Boolean, - parentDisposable: Disposable -) { - var isExpanded by remember { mutableStateOf(true) } - - IdeaCollapsibleCard( - title = "Fix Generation", - isExpanded = isExpanded, - onExpandChange = { isExpanded = it }, - isActive = isGenerating, - badge = { - if (isGenerating) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator() - IdeaBadge(text = "Generating...", color = AutoDevColors.Indigo.c400) - } - } else if (fixOutput.isNotEmpty()) { - IdeaBadge(text = "Complete", color = AutoDevColors.Green.c400) - } - } - ) { - if (fixOutput.isNotEmpty()) { - IdeaSketchRenderer.RenderResponse( - content = fixOutput, - isComplete = !isGenerating, - parentDisposable = parentDisposable, - modifier = Modifier.fillMaxWidth() - ) - } else if (isGenerating) { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - Text( - text = "No fixes generated yet.", - style = JewelTheme.defaultTextStyle.copy( - color = JewelTheme.globalColors.text.info, - fontSize = 12.sp - ) - ) - } - } -} - -/** - * Reusable collapsible card component for sections - */ -@Composable -private 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)) - ) { - // Header - 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( - text = if (isExpanded) "-" else "+", - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - fontSize = 14.sp - ) - ) - - Text( - text = title, - style = JewelTheme.defaultTextStyle.copy( - fontWeight = FontWeight.Bold, - fontSize = 13.sp - ) - ) - - badge?.invoke() - } - } - - // Expandable content - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp, end = 12.dp, bottom = 12.dp) - ) { - content() - } - } - } -} - -/** - * Small badge component for status indicators - */ -@Composable -private 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 = 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/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) } + } + } + } +} From d7207ce0abd5644d6128b859ec1cba6b8f7f022b Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 13:36:32 +0800 Subject: [PATCH 39/60] fix: remove unused onItemSelectionChanged parameter from IdeaModificationPlanSection Address PR review comment by removing the unused parameter. --- .../idea/toolwindow/codereview/IdeaAnalysisComponents.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt index ead7cf10dc..2f073d58d7 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/codereview/IdeaAnalysisComponents.kt @@ -56,7 +56,7 @@ internal fun IdeaAIAnalysisPanel(state: CodeReviewState, viewModel: IdeaCodeRevi item { IdeaAIAnalysisSection(progress.analysisOutput, progress.stage == AnalysisStage.ANALYZING_LINT, parentDisposable) } } if (progress.planOutput.isNotEmpty()) { - item { IdeaModificationPlanSection(progress.planOutput, progress.stage == AnalysisStage.GENERATING_PLAN, parentDisposable) { viewModel.setSelectedPlanItems(it) } } + 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() }) } @@ -152,7 +152,7 @@ internal fun IdeaAIAnalysisSection(analysisOutput: String, isActive: Boolean, pa } @Composable -internal fun IdeaModificationPlanSection(planOutput: String, isActive: Boolean, parentDisposable: Disposable, onItemSelectionChanged: (Set) -> Unit) { +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()) From b9a73c822ccd572df553928fcf6276491d017ec6 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 13:50:03 +0800 Subject: [PATCH 40/60] refactor(markdown): rename SimpleJewelMarkdown to JewelMarkdownRenderer Renamed the file for clarity and consistency with naming conventions. --- .../markdown/{SimpleJewelMarkdown.kt => JewelMarkdownRenderer.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/{SimpleJewelMarkdown.kt => JewelMarkdownRenderer.kt} (100%) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt similarity index 100% rename from mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/SimpleJewelMarkdown.kt rename to mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/markdown/JewelMarkdownRenderer.kt From fe724aae073d4b3e0fd5baea98420d5ebf92b429 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 14:09:56 +0800 Subject: [PATCH 41/60] refactor: extract shared renderer models and utils to mpp-core - Create RendererModels.kt with shared data models: - ToolCallInfo, TaskInfo, TaskStatus, ToolCallDisplayInfo - Create RendererUtils.kt with shared utility methods: - formatToolCallDisplay(), formatToolResultSummary() - parseParamsString(), toToolCallInfo() - Update ComposeRenderer to use shared models and utils - Update JewelRenderer to use shared models and utils - Add UI extensions for TaskStatus (icon, color) in TaskPanel.kt - Update related imports in AgentChatInterface, ToolItem, tests --- .../unitmesh/agent/render/RendererModels.kt | 55 ++++++++ .../cc/unitmesh/agent/render/RendererUtils.kt | 116 +++++++++++++++++ .../devins/idea/renderer/JewelRenderer.kt | 121 ++---------------- .../devins/idea/renderer/JewelRendererTest.kt | 3 +- .../ui/compose/agent/AgentChatInterface.kt | 1 + .../ui/compose/agent/ComposeRenderer.kt | 105 ++------------- .../devins/ui/compose/agent/TaskPanel.kt | 42 +++--- .../devins/ui/compose/agent/ToolItem.kt | 3 +- .../agent/test/AgentMessageListPreviews.kt | 3 +- 9 files changed, 218 insertions(+), 231 deletions(-) create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt create mode 100644 mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt 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..3324ff969c --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -0,0 +1,55 @@ +package cc.unitmesh.agent.render + +import cc.unitmesh.agent.Platform + +/** + * 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 + } + } +} + diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt new file mode 100644 index 0000000000..479f70bfac --- /dev/null +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererUtils.kt @@ -0,0 +1,116 @@ +package cc.unitmesh.agent.render + +import cc.unitmesh.agent.tool.ToolType +import cc.unitmesh.agent.tool.toToolType + +/** + * Shared utility functions for Renderer implementations. + * Used by both ComposeRenderer and JewelRenderer. + */ +object RendererUtils { + + /** + * Format tool call for display in UI. + */ + fun formatToolCallDisplay(toolName: String, paramsStr: String): ToolCallDisplayInfo { + val params = parseParamsString(paramsStr) + val toolType = toolName.toToolType() + + return when (toolType) { + ToolType.ReadFile -> ToolCallDisplayInfo( + toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", + description = "file reader", + details = "Reading file: ${params["path"] ?: "unknown"}" + ) + + ToolType.WriteFile -> ToolCallDisplayInfo( + toolName = "${params["path"] ?: "unknown"} - ${toolType.displayName}", + description = "file writer", + details = "Writing to file: ${params["path"] ?: "unknown"}" + ) + + ToolType.Glob -> ToolCallDisplayInfo( + toolName = toolType.displayName, + description = "pattern matcher", + details = "Searching for files matching pattern: ${params["pattern"] ?: "*"}" + ) + + ToolType.Shell -> ToolCallDisplayInfo( + toolName = toolType.displayName, + description = "command executor", + details = "Executing: ${params["command"] ?: params["cmd"] ?: "unknown command"}" + ) + + else -> ToolCallDisplayInfo( + toolName = if (toolName == "docql") "DocQL" else toolName, + description = "tool execution", + details = paramsStr + ) + } + } + + /** + * Format tool result summary for display. + */ + fun formatToolResultSummary(toolName: String, success: Boolean, output: String?): String { + if (!success) return "Failed" + + val toolType = toolName.toToolType() + return when (toolType) { + ToolType.ReadFile -> { + val lines = output?.lines()?.size ?: 0 + "Read $lines lines" + } + + ToolType.WriteFile -> "File written successfully" + + ToolType.Glob -> { + val firstLine = output?.lines()?.firstOrNull() ?: "" + when { + firstLine.contains("Found ") && firstLine.contains(" files matching") -> { + val count = firstLine.substringAfter("Found ").substringBefore(" files").toIntOrNull() ?: 0 + "Found $count files" + } + output?.contains("No files found") == true -> "No files found" + else -> "Search completed" + } + } + + ToolType.Shell -> { + val lines = output?.lines()?.size ?: 0 + if (lines > 0) "Executed ($lines lines output)" else "Executed successfully" + } + + else -> "Success" + } + } + + /** + * Parse parameter string into a map. + * Handles both quoted and unquoted values. + */ + fun parseParamsString(paramsStr: String): Map { + val params = mutableMapOf() + val regex = Regex("""(\w+)="([^"]*)"|\s*(\w+)=([^\s]+)""") + regex.findAll(paramsStr).forEach { match -> + val key = match.groups[1]?.value ?: match.groups[3]?.value + val value = match.groups[2]?.value ?: match.groups[4]?.value + if (key != null && value != null) { + params[key] = value + } + } + return params + } + + /** + * Convert ToolCallDisplayInfo to ToolCallInfo. + */ + fun toToolCallInfo(displayInfo: ToolCallDisplayInfo): ToolCallInfo { + return ToolCallInfo( + toolName = displayInfo.toolName, + description = displayInfo.description, + details = displayInfo.details + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 4e47dfaeee..e91168e1e3 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,11 @@ package cc.unitmesh.devins.idea.renderer 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.ToolCallDisplayInfo +import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType import cc.unitmesh.llm.compression.TokenInfo @@ -129,42 +134,10 @@ class JewelRenderer : BaseRenderer() { } } - data class ToolCallInfo( - val toolName: String, - val description: String, - val details: String? = null - ) - enum class MessageRole { USER, ASSISTANT, SYSTEM } - /** - * Task information from task-boundary tool. - * Aligned with ComposeRenderer's TaskInfo for consistency. - */ - data class TaskInfo( - val taskName: String, - val status: TaskStatus, - val summary: String = "", - val timestamp: Long = System.currentTimeMillis(), - val startTime: Long = System.currentTimeMillis() - ) - - enum class TaskStatus(val displayName: String) { - PLANNING("Planning"), - WORKING("Working"), - COMPLETED("Completed"), - BLOCKED("Blocked"), - CANCELLED("Cancelled"); - - companion object { - fun fromString(status: String): TaskStatus { - return entries.find { it.name.equals(status, ignoreCase = true) } ?: WORKING - } - } - } - // BaseRenderer implementation override fun renderIterationHeader(current: Int, max: Int) { @@ -479,85 +452,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" - - 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 formatToolCallDisplay(toolName: String, paramsStr: String) = + RendererUtils.formatToolCallDisplay(toolName, paramsStr) - 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/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt index a0957dffc1..7916ccf219 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,6 @@ package cc.unitmesh.devins.idea.renderer +import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.llm.compression.TokenInfo import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -210,7 +211,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-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/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt index be6753fbf2..42e11e85b5 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,6 +2,10 @@ 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.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.impl.docql.DocQLSearchStats import cc.unitmesh.agent.tool.toToolType @@ -141,13 +145,6 @@ class ComposeRenderer : BaseRenderer() { ) : 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( @@ -536,100 +533,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) } /** diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt index cde787a9d7..c1e4a71a61 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/TaskPanel.kt @@ -18,33 +18,31 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.render.TaskInfo +import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.devins.ui.compose.theme.AutoDevColors -import kotlinx.datetime.Clock /** - * Task information from task-boundary tool + * UI extension for TaskStatus - provides icon and color for display */ -data class TaskInfo( - val taskName: String, - val status: TaskStatus, - val summary: String = "", - val timestamp: Long = Clock.System.now().toEpochMilliseconds(), - val startTime: Long = Clock.System.now().toEpochMilliseconds() -) - -enum class TaskStatus(val displayName: String, val icon: @Composable () -> Unit, val color: Color) { - PLANNING("Planning", { Icon(Icons.Default.Create, null) }, Color(0xFF9C27B0)), - WORKING("Working", { Icon(Icons.Default.Build, null) }, Color(0xFF2196F3)), - COMPLETED("Completed", { Icon(Icons.Default.CheckCircle, null) }, Color(0xFF4CAF50)), - BLOCKED("Blocked", { Icon(Icons.Default.Warning, null) }, Color(0xFFFF9800)), - CANCELLED("Cancelled", { Icon(Icons.Default.Cancel, null) }, Color(0xFF9E9E9E)); +@Composable +fun TaskStatus.icon(): Unit = when (this) { + TaskStatus.PLANNING -> Icon(Icons.Default.Create, null) + TaskStatus.WORKING -> Icon(Icons.Default.Build, null) + TaskStatus.COMPLETED -> Icon(Icons.Default.CheckCircle, null) + TaskStatus.BLOCKED -> Icon(Icons.Default.Warning, null) + TaskStatus.CANCELLED -> Icon(Icons.Default.Cancel, null) +} - companion object { - fun fromString(status: String): TaskStatus { - return entries.find { it.name.equals(status, ignoreCase = true) } ?: WORKING - } +val TaskStatus.color: Color + get() = when (this) { + TaskStatus.PLANNING -> Color(0xFF9C27B0) + TaskStatus.WORKING -> Color(0xFF2196F3) + TaskStatus.COMPLETED -> Color(0xFF4CAF50) + TaskStatus.BLOCKED -> Color(0xFFFF9800) + TaskStatus.CANCELLED -> Color(0xFF9E9E9E) } -} /** * Task Panel Component - displays active tasks from task-boundary tool @@ -225,7 +223,7 @@ private fun TaskCard(task: TaskInfo, modifier: Modifier = Modifier) { } // Time elapsed - val elapsed = (Clock.System.now().toEpochMilliseconds() - task.startTime) / 1000 + val elapsed = (Platform.getCurrentTimestamp() - task.startTime) / 1000 Text( formatDuration(elapsed), style = MaterialTheme.typography.labelSmall, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt index 01478a519d..ea8cfe6ba0 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ToolItem.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons @Composable @@ -265,7 +266,7 @@ fun ToolErrorItem( } @Composable -fun CurrentToolCallItem(toolCall: ComposeRenderer.ToolCallInfo) { +fun CurrentToolCallItem(toolCall: ToolCallInfo) { Surface( color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(8.dp), diff --git a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt index 7fc3d64c86..3239deba94 100644 --- a/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt +++ b/mpp-ui/src/jvmMain/kotlin/cc/unitmesh/devins/ui/compose/agent/test/AgentMessageListPreviews.kt @@ -6,6 +6,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.ui.compose.agent.AgentMessageList @@ -101,7 +102,7 @@ fun Preview_CurrentToolCallItem() { MaterialTheme { Surface { CurrentToolCallItem( - toolCall = ComposeRenderer.ToolCallInfo( + toolCall = ToolCallInfo( toolName = "Shell", description = "Executing sample command", details = "Executing: echo hello" From 761c3c96d8a629d030c10ee7a6c33d48d28c45d6 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 14:30:22 +0800 Subject: [PATCH 42/60] refactor: extract TimelineItem and MessageRole to shared mpp-core module - Move TimelineItem sealed class from JewelRenderer to RendererModels.kt - Move MessageRole enum to shared module - Update all references in mpp-idea components and tests - Maintain compatibility with ComposeRenderer's TimelineItem structure --- .../unitmesh/agent/render/RendererModels.kt | 86 +++++++++++++++++++ .../components/timeline/IdeaMessageBubble.kt | 6 +- .../timeline/IdeaTaskCompleteBubble.kt | 4 +- .../timeline/IdeaTerminalOutputBubble.kt | 4 +- .../timeline/IdeaTimelineContent.kt | 16 ++-- .../components/timeline/IdeaToolCallBubble.kt | 8 +- .../devins/idea/renderer/JewelRenderer.kt | 66 +------------- .../knowledge/IdeaKnowledgeContent.kt | 23 ++--- .../devins/idea/renderer/JewelRendererTest.kt | 18 ++-- .../remote/IdeaRemoteAgentViewModelTest.kt | 18 ++-- 10 files changed, 139 insertions(+), 110 deletions(-) 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 index 3324ff969c..29a3ca9499 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -1,6 +1,8 @@ package cc.unitmesh.agent.render import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.tool.ToolType +import cc.unitmesh.llm.compression.TokenInfo /** * Shared data models for Renderer implementations. @@ -53,3 +55,87 @@ enum class TaskStatus(val displayName: String) { } } +/** + * Message role for timeline messages. + */ +enum class MessageRole { + USER, ASSISTANT, SYSTEM +} + +/** + * Base timeline item for chronological rendering. + * This is the shared base class for timeline items in both ComposeRenderer and JewelRenderer. + */ +sealed class TimelineItem( + open val timestamp: Long = Platform.getCurrentTimestamp(), + open val id: String = generateId() +) { + /** + * Message item for user/assistant/system messages. + */ + data class MessageItem( + val role: MessageRole, + val content: String, + val tokenInfo: TokenInfo? = null, + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, 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, + 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) + + companion object { + private var idCounter = 0L + fun generateId(): String = "${Platform.getCurrentTimestamp()}-${idCounter++}" + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt index c9ebc1e066..616d3df0ce 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.MessageRole import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text import org.jetbrains.jewel.ui.theme.defaultBannerStyle @@ -16,11 +16,11 @@ import org.jetbrains.jewel.ui.theme.defaultBannerStyle */ @Composable fun IdeaMessageBubble( - role: JewelRenderer.MessageRole, + role: MessageRole, content: String, modifier: Modifier = Modifier ) { - val isUser = role == JewelRenderer.MessageRole.USER + val isUser = role == MessageRole.USER Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt index f739cf2481..6c30a2efc5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTaskCompleteBubble.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import org.jetbrains.jewel.foundation.theme.JewelTheme @@ -21,7 +21,7 @@ import org.jetbrains.jewel.ui.component.Text */ @Composable fun IdeaTaskCompleteBubble( - item: JewelRenderer.TimelineItem.TaskCompleteItem, + item: TimelineItem.TaskCompleteItem, modifier: Modifier = Modifier ) { Row( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt index 331f0f19f9..0e80fa194d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.ide.CopyPasteManager @@ -34,7 +34,7 @@ import java.awt.datatransfer.StringSelection */ @Composable fun IdeaTerminalOutputBubble( - item: JewelRenderer.TimelineItem.TerminalOutputItem, + item: TimelineItem.TerminalOutputItem, modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(true) } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index 7ea4769a0d..fffb915841 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text @@ -19,7 +19,7 @@ import org.jetbrains.jewel.ui.component.Text */ @Composable fun IdeaTimelineContent( - timeline: List, + timeline: List, streamingOutput: String, listState: LazyListState, modifier: Modifier = Modifier @@ -51,24 +51,24 @@ fun IdeaTimelineContent( * Dispatch timeline item to appropriate bubble component. */ @Composable -fun IdeaTimelineItemView(item: JewelRenderer.TimelineItem) { +fun IdeaTimelineItemView(item: TimelineItem) { when (item) { - is JewelRenderer.TimelineItem.MessageItem -> { + is TimelineItem.MessageItem -> { IdeaMessageBubble( role = item.role, content = item.content ) } - is JewelRenderer.TimelineItem.ToolCallItem -> { + is TimelineItem.ToolCallItem -> { IdeaToolCallBubble(item) } - is JewelRenderer.TimelineItem.ErrorItem -> { + is TimelineItem.ErrorItem -> { IdeaErrorBubble(item.message) } - is JewelRenderer.TimelineItem.TaskCompleteItem -> { + is TimelineItem.TaskCompleteItem -> { IdeaTaskCompleteBubble(item) } - is JewelRenderer.TimelineItem.TerminalOutputItem -> { + is TimelineItem.TerminalOutputItem -> { IdeaTerminalOutputBubble(item) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt index 45cb389413..2f82880e33 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaToolCallBubble.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.ui.compose.theme.AutoDevColors import com.intellij.openapi.ide.CopyPasteManager @@ -39,7 +39,7 @@ import java.awt.datatransfer.StringSelection */ @Composable fun IdeaToolCallBubble( - item: JewelRenderer.TimelineItem.ToolCallItem, + item: TimelineItem.ToolCallItem, modifier: Modifier = Modifier ) { // Auto-expand on error @@ -123,8 +123,8 @@ fun IdeaToolCallBubble( } // Execution time (if available) - item.executionTimeMs?.let { time -> - if (time > 0) { + item.executionTimeMs?.let { time: Long -> + if (time > 0L) { Text( text = "${time}ms", style = JewelTheme.defaultTextStyle.copy( diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index e91168e1e3..9bec188ef1 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,10 +1,11 @@ package cc.unitmesh.devins.idea.renderer import cc.unitmesh.agent.render.BaseRenderer +import cc.unitmesh.agent.render.MessageRole import cc.unitmesh.agent.render.RendererUtils import cc.unitmesh.agent.render.TaskInfo import cc.unitmesh.agent.render.TaskStatus -import cc.unitmesh.agent.render.ToolCallDisplayInfo +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.agent.render.ToolCallInfo import cc.unitmesh.agent.tool.ToolType import cc.unitmesh.agent.tool.toToolType @@ -75,69 +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++}" - } - } - - enum class MessageRole { - USER, ASSISTANT, SYSTEM - } - // BaseRenderer implementation override fun renderIterationHeader(current: Int, max: Int) { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index 425fef0f8b..35f40a522f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -14,7 +14,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd -import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.agent.render.MessageRole +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.toolwindow.IdeaComposeIcons import cc.unitmesh.devins.idea.components.IdeaResizableSplitPane import cc.unitmesh.devins.idea.components.IdeaVerticalResizableSplitPane @@ -443,7 +444,7 @@ private fun DocumentContentPanel( */ @Composable private fun AIChatPanel( - timeline: List, + timeline: List, streamingOutput: String, isGenerating: Boolean, onSendMessage: (String) -> Unit, @@ -602,10 +603,10 @@ private fun AIChatPanel( * Chat message item renderer */ @Composable -private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { +private fun ChatMessageItem(item: TimelineItem) { when (item) { - is JewelRenderer.TimelineItem.MessageItem -> { - val isUser = item.role == JewelRenderer.MessageRole.USER + is TimelineItem.MessageItem -> { + val isUser = item.role == MessageRole.USER Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start @@ -627,7 +628,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.ToolCallItem -> { + is TimelineItem.ToolCallItem -> { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start @@ -665,10 +666,10 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { ) ) } - if (item.output != null) { + item.output?.let { output -> Spacer(modifier = Modifier.height(4.dp)) Text( - text = item.output.take(200) + if (item.output.length > 200) "..." else "", + text = output.take(200) + if (output.length > 200) "..." else "", style = JewelTheme.defaultTextStyle.copy(fontSize = 11.sp) ) } @@ -677,7 +678,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.ErrorItem -> { + is TimelineItem.ErrorItem -> { Box( modifier = Modifier .fillMaxWidth() @@ -705,7 +706,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.TaskCompleteItem -> { + is TimelineItem.TaskCompleteItem -> { Box( modifier = Modifier .fillMaxWidth() @@ -726,7 +727,7 @@ private fun ChatMessageItem(item: JewelRenderer.TimelineItem) { } } - is JewelRenderer.TimelineItem.TerminalOutputItem -> { + is TimelineItem.TerminalOutputItem -> { Box( modifier = Modifier .fillMaxWidth() 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 7916ccf219..a29e434285 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,6 +1,8 @@ package cc.unitmesh.devins.idea.renderer +import cc.unitmesh.agent.render.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 @@ -53,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) } @@ -91,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 @@ -111,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) } @@ -125,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 @@ -135,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) @@ -200,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 diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt index 0340e0598e..44dbfbd18d 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -1,5 +1,7 @@ package cc.unitmesh.devins.idea.toolwindow.remote +import cc.unitmesh.agent.render.MessageRole +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.renderer.JewelRenderer import kotlinx.coroutines.* import kotlinx.coroutines.flow.first @@ -103,7 +105,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - assertTrue(timeline.first() is JewelRenderer.TimelineItem.ToolCallItem) + assertTrue(timeline.first() is TimelineItem.ToolCallItem) } @Test @@ -126,7 +128,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val toolItem = timeline.first() as JewelRenderer.TimelineItem.ToolCallItem + val toolItem = timeline.first() as TimelineItem.ToolCallItem assertEquals(true, toolItem.success) } @@ -141,7 +143,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - assertTrue(timeline.first() is JewelRenderer.TimelineItem.ErrorItem) + assertTrue(timeline.first() is TimelineItem.ErrorItem) } @Test @@ -153,7 +155,7 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val item = timeline.first() as JewelRenderer.TimelineItem.TaskCompleteItem + val item = timeline.first() as TimelineItem.TaskCompleteItem assertTrue(item.success) assertEquals("Task completed", item.message) assertEquals(5, item.iterations) @@ -199,8 +201,8 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertTrue(timeline.isNotEmpty()) val lastItem = timeline.last() - assertTrue(lastItem is JewelRenderer.TimelineItem.MessageItem) - assertTrue((lastItem as JewelRenderer.TimelineItem.MessageItem).content.contains("[Interrupted]")) + assertTrue(lastItem is TimelineItem.MessageItem) + assertTrue((lastItem as TimelineItem.MessageItem).content.contains("[Interrupted]")) } @Test @@ -225,8 +227,8 @@ class IdeaRemoteAgentViewModelTest { val timeline = renderer.timeline.first() assertEquals(1, timeline.size) - val item = timeline.first() as JewelRenderer.TimelineItem.MessageItem - assertEquals(JewelRenderer.MessageRole.USER, item.role) + val item = timeline.first() as TimelineItem.MessageItem + assertEquals(MessageRole.USER, item.role) assertEquals("Hello from user", item.content) } From 3c1a8a0a0ac291002735d394a277a863fef6060f Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 14:50:59 +0800 Subject: [PATCH 43/60] refactor: update ComposeRenderer to use shared TimelineItem from mpp-core - Remove local TimelineItem sealed class from ComposeRenderer - Update imports to use shared TimelineItem from cc.unitmesh.agent.render - Replace CombinedToolItem with ToolCallItem - Replace ToolErrorItem with ErrorItem - Update MessageItem to handle nullable message field - Update toMessageMetadata and fromMessageMetadata methods - Add LiveTerminalItem handling in mpp-idea components - Update MessageRole imports to use cc.unitmesh.devins.llm.MessageRole --- .../unitmesh/agent/render/RendererModels.kt | 55 ++++-- .../components/timeline/IdeaMessageBubble.kt | 2 +- .../timeline/IdeaTimelineContent.kt | 11 ++ .../devins/idea/renderer/JewelRenderer.kt | 2 +- .../knowledge/IdeaKnowledgeContent.kt | 30 ++- .../devins/idea/renderer/JewelRendererTest.kt | 2 +- .../remote/IdeaRemoteAgentViewModelTest.kt | 2 +- .../ui/compose/agent/AgentMessageList.kt | 36 ++-- .../ui/compose/agent/ComposeRenderer.kt | 178 ++++++------------ 9 files changed, 156 insertions(+), 162 deletions(-) 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 index 29a3ca9499..1a9b39ceb6 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -2,6 +2,9 @@ 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 /** @@ -55,13 +58,6 @@ enum class TaskStatus(val displayName: String) { } } -/** - * Message role for timeline messages. - */ -enum class MessageRole { - USER, ASSISTANT, SYSTEM -} - /** * Base timeline item for chronological rendering. * This is the shared base class for timeline items in both ComposeRenderer and JewelRenderer. @@ -72,14 +68,34 @@ sealed class TimelineItem( ) { /** * Message item for user/assistant/system messages. + * Supports both simple role+content and full Message object. */ data class MessageItem( - val role: MessageRole, - val content: String, + val message: Message? = null, + val role: MessageRole = message?.role ?: MessageRole.USER, + val content: String = message?.content ?: "", val tokenInfo: TokenInfo? = null, - override val timestamp: Long = Platform.getCurrentTimestamp(), + override val timestamp: Long = message?.timestamp ?: Platform.getCurrentTimestamp(), override val id: String = generateId() - ) : TimelineItem(timestamp, id) + ) : 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. @@ -88,7 +104,7 @@ sealed class TimelineItem( data class ToolCallItem( val toolName: String, val description: String = "", - val params: String, + val params: String = "", val fullParams: String? = null, val filePath: String? = null, val toolType: ToolType? = null, @@ -97,6 +113,8 @@ sealed class TimelineItem( 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) @@ -133,6 +151,19 @@ sealed class TimelineItem( override val id: String = generateId() ) : TimelineItem(timestamp, id) + /** + * Live terminal session - connected to a PTY process for real-time output. + * This is only used on platforms that support PTY (JVM with JediTerm). + */ + data class LiveTerminalItem( + val sessionId: String, + val command: String, + val workingDirectory: String?, + val ptyHandle: Any?, // Platform-specific: on JVM this is a PtyProcess + override val timestamp: Long = Platform.getCurrentTimestamp(), + override val id: String = generateId() + ) : TimelineItem(timestamp, id) + companion object { private var idCounter = 0L fun generateId(): String = "${Platform.getCurrentTimestamp()}-${idCounter++}" diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt index 616d3df0ce..9cac0bfe30 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaMessageBubble.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cc.unitmesh.agent.render.MessageRole +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 diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index fffb915841..bf4b6e51e7 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -71,6 +71,17 @@ fun IdeaTimelineItemView(item: TimelineItem) { is TimelineItem.TerminalOutputItem -> { IdeaTerminalOutputBubble(item) } + is TimelineItem.LiveTerminalItem -> { + // Live terminal not supported in IDEA yet, show placeholder + IdeaTerminalOutputBubble( + TimelineItem.TerminalOutputItem( + command = item.command, + output = "[Live terminal session: ${item.sessionId}]", + exitCode = 0, + executionTimeMs = 0 + ) + ) + } } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt index 9bec188ef1..da5a05efd5 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/JewelRenderer.kt @@ -1,7 +1,7 @@ package cc.unitmesh.devins.idea.renderer import cc.unitmesh.agent.render.BaseRenderer -import cc.unitmesh.agent.render.MessageRole +import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.agent.render.RendererUtils import cc.unitmesh.agent.render.TaskInfo import cc.unitmesh.agent.render.TaskStatus diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt index 35f40a522f..fb860b8086 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/knowledge/IdeaKnowledgeContent.kt @@ -14,7 +14,7 @@ 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.agent.render.MessageRole +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 @@ -753,6 +753,34 @@ private fun ChatMessageItem(item: TimelineItem) { } } } + + is TimelineItem.LiveTerminalItem -> { + // Live terminal not supported in knowledge content, show placeholder + Box( + modifier = Modifier + .fillMaxWidth() + .background(AutoDevColors.Neutral.c900) + .padding(8.dp) + ) { + Column { + Text( + text = "$ ${item.command}", + style = JewelTheme.defaultTextStyle.copy( + fontWeight = FontWeight.Bold, + color = AutoDevColors.Cyan.c400, + fontSize = 12.sp + ) + ) + Text( + text = "[Live terminal session: ${item.sessionId}]", + style = JewelTheme.defaultTextStyle.copy( + color = AutoDevColors.Neutral.c300, + fontSize = 11.sp + ) + ) + } + } + } } } diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/renderer/JewelRendererTest.kt index a29e434285..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,6 +1,6 @@ package cc.unitmesh.devins.idea.renderer -import cc.unitmesh.agent.render.MessageRole +import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.agent.render.TaskStatus import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.llm.compression.TokenInfo diff --git a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt index 44dbfbd18d..b26c454cf7 100644 --- a/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt +++ b/mpp-idea/src/test/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteAgentViewModelTest.kt @@ -1,6 +1,6 @@ package cc.unitmesh.devins.idea.toolwindow.remote -import cc.unitmesh.agent.render.MessageRole +import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.idea.renderer.JewelRenderer import kotlinx.coroutines.* diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt index d7e8308be8..40fe81236c 100644 --- a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt +++ b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/AgentMessageList.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.sp import autodev_intellij.mpp_ui.generated.resources.NotoSansSC_Regular import autodev_intellij.mpp_ui.generated.resources.Res import cc.unitmesh.agent.Platform +import cc.unitmesh.agent.render.TimelineItem import cc.unitmesh.devins.llm.Message import cc.unitmesh.devins.llm.MessageRole import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons @@ -159,23 +160,28 @@ fun AgentMessageList( @Composable fun RenderMessageItem( - timelineItem: ComposeRenderer.TimelineItem, + timelineItem: TimelineItem, onOpenFileViewer: ((String) -> Unit)?, renderer: ComposeRenderer, onExpand: () -> Unit = {} ) { when (timelineItem) { - is ComposeRenderer.TimelineItem.MessageItem -> { + is TimelineItem.MessageItem -> { + val msg = timelineItem.message ?: Message( + role = timelineItem.role, + content = timelineItem.content, + timestamp = timelineItem.timestamp + ) MessageItem( - message = timelineItem.message, + message = msg, tokenInfo = timelineItem.tokenInfo ) } - is ComposeRenderer.TimelineItem.CombinedToolItem -> { + is TimelineItem.ToolCallItem -> { ToolItem( toolName = timelineItem.toolName, - details = timelineItem.details, + details = timelineItem.params, fullParams = timelineItem.fullParams, filePath = timelineItem.filePath, toolType = timelineItem.toolType, @@ -190,28 +196,18 @@ fun RenderMessageItem( ) } - is ComposeRenderer.TimelineItem.ToolResultItem -> { - ToolResultItem( - toolName = timelineItem.toolName, - success = timelineItem.success, - summary = timelineItem.summary, - output = timelineItem.output, - fullOutput = timelineItem.fullOutput - ) - } - - is ComposeRenderer.TimelineItem.ToolErrorItem -> { - ToolErrorItem(error = timelineItem.error, onDismiss = { renderer.clearError() }) + is TimelineItem.ErrorItem -> { + ToolErrorItem(error = timelineItem.message, onDismiss = { renderer.clearError() }) } - is ComposeRenderer.TimelineItem.TaskCompleteItem -> { + is TimelineItem.TaskCompleteItem -> { TaskCompletedItem( success = timelineItem.success, message = timelineItem.message ) } - is ComposeRenderer.TimelineItem.TerminalOutputItem -> { + is TimelineItem.TerminalOutputItem -> { TerminalOutputItem( command = timelineItem.command, output = timelineItem.output, @@ -221,7 +217,7 @@ fun RenderMessageItem( ) } - is ComposeRenderer.TimelineItem.LiveTerminalItem -> { + is TimelineItem.LiveTerminalItem -> { LiveTerminalItem( sessionId = timelineItem.sessionId, command = timelineItem.command, diff --git a/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt b/mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/ComposeRenderer.kt index 42e11e85b5..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 @@ -5,6 +5,8 @@ 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 @@ -12,7 +14,6 @@ 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 @@ -74,77 +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) - } - // BaseRenderer implementation override fun renderIterationHeader( @@ -227,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, // 保存工具类型 @@ -326,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) } @@ -342,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) @@ -412,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 } @@ -574,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, @@ -603,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 ) } @@ -664,10 +584,10 @@ class ComposeRenderer : BaseRenderer() { ) } else null - TimelineItem.MessageItem( + MessageItem( message = message, tokenInfo = tokenInfo, - itemTimestamp = message.timestamp + timestamp = message.timestamp ) } @@ -698,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(), @@ -711,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 ) } @@ -737,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 @@ -768,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 ) } @@ -786,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 } } } From 7441c918268f7b9ef51e975eba1987a57efa4d52 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 16:24:52 +0800 Subject: [PATCH 44/60] fix: resolve Android compilation error by replacing iOS reference with Android-specific CodeParser - Remove incorrect import of IosCodeParser from androidMain - Create AndroidCodeParser class with regex-based parsing (similar to iOS implementation) - Support parsing for Java, Kotlin, JavaScript, TypeScript, and Python - Fix CI build failure in kmp-test.yml workflow --- .../tool/impl/AndroidCodeParserFactory.kt | 251 +++++++++++++++++- 1 file changed, 244 insertions(+), 7 deletions(-) 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..2c703a9bee 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,252 @@ 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).mapIndexed { index, match -> + ImportInfo( + path = match.groupValues[2].removeSuffix(".*"), + type = ImportType.MODULE, + filePath = filePath, + startLine = index, + endLine = index + ) + }.toList() + } + + private fun extractPythonImports(content: String, filePath: String): List { + val imports = mutableListOf() + var lineIndex = 0 + + val fromImportRegex = Regex("""from\s+([\w.]+)\s+import""") + fromImportRegex.findAll(content).forEach { match -> + imports.add(ImportInfo( + path = match.groupValues[1], + type = ImportType.MODULE, + filePath = filePath, + startLine = lineIndex++, + endLine = lineIndex + )) + } + + val importRegex = Regex("""^import\s+([\w.]+)""", RegexOption.MULTILINE) + importRegex.findAll(content).forEach { match -> + imports.add(ImportInfo( + path = match.groupValues[1], + type = ImportType.MODULE, + filePath = filePath, + startLine = lineIndex++, + endLine = lineIndex + )) + } + + return imports + } + + private fun extractJsImports(content: String, filePath: String): List { + val imports = mutableListOf() + var lineIndex = 0 + + val es6ImportRegex = Regex("""import\s+(?:.+\s+from\s+)?['"]([@\w./-]+)['"]""") + es6ImportRegex.findAll(content).forEach { match -> + imports.add(ImportInfo( + path = match.groupValues[1], + type = ImportType.MODULE, + filePath = filePath, + startLine = lineIndex++, + endLine = lineIndex + )) + } + + 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 + + return CodeNode( + id = qualifiedName.hashCode().toString(), + type = type, + name = name, + packageName = packageName, + filePath = filePath, + startLine = startLine, + 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() + } } From 272614a3b0ee76bb843fafb96fe902b4c4d114ac Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 16:54:41 +0800 Subject: [PATCH 45/60] fix: address PR review comments for thread-safety and line number tracking RendererModels.kt: - Replace mutable idCounter with random-based ID generation for thread-safety - Add documentation explaining copy() behavior with default parameters AndroidCodeParserFactory.kt: - Fix line number tracking in extractJvmImports, extractPythonImports, extractJsImports to use actual line numbers from match positions instead of sequential counters - Replace hashCode-based ID with deterministic composite ID (filePath:startLine:qualifiedName) - Add comment explaining the approximate endLine calculation for regex-based parsing --- .../tool/impl/AndroidCodeParserFactory.kt | 34 ++++++++++++------- .../unitmesh/agent/render/RendererModels.kt | 23 +++++++++++-- 2 files changed, 43 insertions(+), 14 deletions(-) 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 2c703a9bee..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 @@ -83,40 +83,45 @@ private class AndroidCodeParser : CodeParser { private fun extractJvmImports(content: String, filePath: String): List { val importRegex = Regex("""import\s+(static\s+)?([a-zA-Z_][\w.]*[\w*])""") - return importRegex.findAll(content).mapIndexed { index, match -> + 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 = index, - endLine = index + startLine = lineNumber, + endLine = lineNumber ) }.toList() } private fun extractPythonImports(content: String, filePath: String): List { val imports = mutableListOf() - var lineIndex = 0 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 = lineIndex++, - endLine = lineIndex + 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 = lineIndex++, - endLine = lineIndex + startLine = lineNumber, + endLine = lineNumber )) } @@ -125,16 +130,17 @@ private class AndroidCodeParser : CodeParser { private fun extractJsImports(content: String, filePath: String): List { val imports = mutableListOf() - var lineIndex = 0 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 = lineIndex++, - endLine = lineIndex + startLine = lineNumber, + endLine = lineNumber )) } @@ -228,14 +234,18 @@ private class AndroidCodeParser : CodeParser { 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 = qualifiedName.hashCode().toString(), + 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, 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 index 1a9b39ceb6..d8f5c94277 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -61,6 +61,16 @@ enum class TaskStatus(val displayName: String) { /** * 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(), @@ -165,8 +175,17 @@ sealed class TimelineItem( ) : TimelineItem(timestamp, id) companion object { - private var idCounter = 0L - fun generateId(): String = "${Platform.getCurrentTimestamp()}-${idCounter++}" + /** + * 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)}" } } From 2ea1df1ded358703c26b157b862c613cf8cc9268 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 17:44:04 +0800 Subject: [PATCH 46/60] feat(idea): add Jewel-themed ANSI terminal renderer Introduce a custom ANSI terminal renderer for IntelliJ IDEA using Jewel theming, with support for colors and formatting. Enable "Open in Terminal" for command outputs via a compatibility layer for different IDEA versions. Persist agent type preference to config to avoid UI flicker. --- .../timeline/IdeaTerminalOutputBubble.kt | 104 +++++---- .../timeline/IdeaTimelineContent.kt | 15 +- .../terminal/IdeaAnsiTerminalRenderer.kt | 208 ++++++++++++++++++ .../devins/idea/terminal/TerminalApiCompat.kt | 140 ++++++++++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 3 +- .../idea/toolwindow/IdeaAgentViewModel.kt | 44 +++- .../remote/IdeaRemoteAgentContent.kt | 3 +- .../remote/IdeaRemoteAgentViewModel.kt | 2 +- .../src/main/resources/META-INF/plugin.xml | 1 + 9 files changed, 463 insertions(+), 57 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/renderer/terminal/IdeaAnsiTerminalRenderer.kt create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/terminal/TerminalApiCompat.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt index 0e80fa194d..052f95d2e1 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTerminalOutputBubble.kt @@ -7,22 +7,22 @@ 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.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.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.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 @@ -30,20 +30,22 @@ import java.awt.datatransfer.StringSelection /** * Terminal output bubble for displaying shell command results. - * Shows output with scrollable area (4 lines visible), full width layout. + * 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 + modifier: Modifier = Modifier, + project: Project? = null ) { var expanded by remember { mutableStateOf(true) } - val scrollState = rememberScrollState() - - // Auto-scroll to bottom when output changes - LaunchedEffect(item.output) { - scrollState.animateScrollTo(scrollState.maxValue) - } Box( modifier = modifier @@ -61,52 +63,46 @@ fun IdeaTerminalOutputBubble( onExpandToggle = { expanded = !expanded }, onCopy = { CopyPasteManager.getInstance().setContents(StringSelection(item.output)) + }, + onOpenInTerminal = project?.let { proj -> + { openCommandInTerminal(proj, item.command) } } ) - // Collapsible output content with scrolling + // Collapsible output content using Jewel ANSI terminal renderer AnimatedVisibility( visible = expanded, enter = expandVertically(), exit = shrinkVertically() ) { - Box( + // Use Jewel-themed ANSI terminal renderer + IdeaAnsiTerminalRenderer( + ansiText = item.output, modifier = Modifier .fillMaxWidth() - .heightIn(max = 120.dp) // ~4 lines at 12sp + padding - .background(Color(0xFF1E1E1E)) - .padding(12.dp) - ) { - if (item.output.isNotEmpty()) { - Text( - text = item.output, - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = AutoDevColors.Neutral.c300, - lineHeight = 18.sp - ), - modifier = Modifier.verticalScroll(scrollState) - ) - } else { - Text( - text = "(No output)", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = AutoDevColors.Neutral.c500 - ) - ) - } - } + .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 copy button. + * Header component for terminal bubble with command, status, and action buttons. */ @Composable private fun TerminalHeader( @@ -115,7 +111,8 @@ private fun TerminalHeader( executionTimeMs: Long, expanded: Boolean, onExpandToggle: () -> Unit, - onCopy: () -> Unit + onCopy: () -> Unit, + onOpenInTerminal: (() -> Unit)? = null ) { Row( modifier = Modifier @@ -152,7 +149,7 @@ private fun TerminalHeader( ) // Command text (truncated if too long) - val displayCommand = if (command.length > 60) command.take(60) + "..." else command + val displayCommand = if (command.length > 50) command.take(50) + "..." else command Text( text = "$ $displayCommand", style = JewelTheme.defaultTextStyle.copy( @@ -164,7 +161,7 @@ private fun TerminalHeader( ) } - // Right side: Status and copy + // Right side: Status and actions Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically @@ -172,6 +169,25 @@ private fun TerminalHeader( // 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 @@ -206,6 +222,7 @@ private fun TerminalStatusBadge( AutoDevColors.Green.c400, "exit: 0 ${executionTimeMs}ms" ) + else -> Triple( AutoDevColors.Red.c600.copy(alpha = 0.3f), AutoDevColors.Red.c400, @@ -228,4 +245,3 @@ private fun TerminalStatusBadge( ) } } - diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt index bf4b6e51e7..323b36873e 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/components/timeline/IdeaTimelineContent.kt @@ -10,6 +10,7 @@ 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 @@ -22,7 +23,8 @@ fun IdeaTimelineContent( timeline: List, streamingOutput: String, listState: LazyListState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + project: Project? = null ) { if (timeline.isEmpty() && streamingOutput.isEmpty()) { IdeaEmptyStateMessage("Start a conversation with your AI Assistant!") @@ -34,7 +36,7 @@ fun IdeaTimelineContent( verticalArrangement = Arrangement.spacedBy(4.dp) ) { items(timeline, key = { it.id }) { item -> - IdeaTimelineItemView(item) + IdeaTimelineItemView(item, project) } // Show streaming output @@ -51,7 +53,7 @@ fun IdeaTimelineContent( * Dispatch timeline item to appropriate bubble component. */ @Composable -fun IdeaTimelineItemView(item: TimelineItem) { +fun IdeaTimelineItemView(item: TimelineItem, project: Project? = null) { when (item) { is TimelineItem.MessageItem -> { IdeaMessageBubble( @@ -69,17 +71,18 @@ fun IdeaTimelineItemView(item: TimelineItem) { IdeaTaskCompleteBubble(item) } is TimelineItem.TerminalOutputItem -> { - IdeaTerminalOutputBubble(item) + IdeaTerminalOutputBubble(item, project = project) } is TimelineItem.LiveTerminalItem -> { // Live terminal not supported in IDEA yet, show placeholder IdeaTerminalOutputBubble( - TimelineItem.TerminalOutputItem( + item = TimelineItem.TerminalOutputItem( command = item.command, output = "[Live terminal session: ${item.sessionId}]", exitCode = 0, executionTimeMs = 0 - ) + ), + project = project ) } } 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..e0846bb813 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/terminal/TerminalApiCompat.kt @@ -0,0 +1,140 @@ +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 ToolWindowManager to open terminal tool window. + */ + private fun tryFallbackTerminalApi( + project: Project, + command: String, + tabName: String?, + requestFocus: Boolean + ): Boolean { + try { + // Try to use ToolWindowManager to activate terminal + val toolWindowManager = com.intellij.openapi.wm.ToolWindowManager.getInstance(project) + val terminalToolWindow = toolWindowManager.getToolWindow("Terminal") + + if (terminalToolWindow != null) { + if (requestFocus) { + terminalToolWindow.activate(null) + } + LOG.info("Activated Terminal tool window (command not sent): $command") + return true + } + + LOG.warn("Terminal tool window not found") + return false + } catch (e: Exception) { + LOG.warn("Fallback terminal API failed: ${e.message}", e) + return 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 0afa6d1cb3..68f627a5ea 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 @@ -158,7 +158,8 @@ fun IdeaAgentApp( IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, - listState = listState + listState = listState, + project = project ) } AgentType.REMOTE -> { 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 d86dae858c..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}") + } + } } /** 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 index c6dbd68ad4..b37f6424f8 100644 --- 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 @@ -88,7 +88,8 @@ fun IdeaRemoteAgentContent( IdeaTimelineContent( timeline = timeline, streamingOutput = streamingOutput, - listState = listState + listState = listState, + project = viewModel.project ) } } 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 index 8f8a2d2e3c..005fad22e9 100644 --- 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 @@ -19,7 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow * This is adapted from mpp-ui's RemoteCodingAgentViewModel. */ class IdeaRemoteAgentViewModel( - private val project: Project, + val project: Project, private val coroutineScope: CoroutineScope, serverUrl: String = "http://localhost:8080", private val useServerConfig: Boolean = false diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index d2f4c2aa10..64855cd9a8 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -12,6 +12,7 @@ + messages.AutoDevIdeaBundle From 92a5b3c1dd7622308e2e165f15b315c1c68d14c9 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 19:42:08 +0800 Subject: [PATCH 47/60] feat(terminal): add completion status to LiveTerminalItem Show exit code, execution time, and output for live terminal sessions after completion. Prevent summarization or replacement of real-time terminal output. Improve fallback terminal API handling for older IDEA versions. --- .../agent/executor/CodingAgentExecutor.kt | 7 ++ .../agent/executor/DocumentAgentExecutor.kt | 22 ++++-- .../unitmesh/agent/render/RendererModels.kt | 18 ++++- .../devins/idea/renderer/JewelRenderer.kt | 28 +++++--- .../devins/idea/terminal/TerminalApiCompat.kt | 68 +++++++++++++++---- .../ui/compose/agent/AgentMessageList.kt | 14 +++- .../ui/compose/agent/LiveTerminalItem.ios.kt | 5 +- .../ui/compose/agent/LiveTerminalItem.js.kt | 5 +- .../ui/compose/agent/LiveTerminalItem.jvm.kt | 59 ++++++++++------ .../compose/agent/LiveTerminalItem.wasmJs.kt | 5 +- 10 files changed, 174 insertions(+), 57 deletions(-) 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/render/RendererModels.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt index d8f5c94277..67d5ff4b72 100644 --- a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt +++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/render/RendererModels.kt @@ -164,15 +164,31 @@ sealed class TimelineItem( /** * 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) + ) : 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 { /** 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 da5a05efd5..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 @@ -213,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 -> 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 index e0846bb813..698ba85b0d 100644 --- 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 @@ -96,7 +96,7 @@ object TerminalApiCompat { /** * Fallback approach for older IDEA versions or when new API is not available. - * Uses ToolWindowManager to open terminal tool window. + * Uses TerminalToolWindowManager to create shell widget and execute command. */ private fun tryFallbackTerminalApi( project: Project, @@ -105,23 +105,67 @@ object TerminalApiCompat { requestFocus: Boolean ): Boolean { try { - // Try to use ToolWindowManager to activate terminal + // 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) { - if (requestFocus) { - terminalToolWindow.activate(null) - } - LOG.info("Activated Terminal tool window (command not sent): $command") + + 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") - return false + false } catch (e: Exception) { - LOG.warn("Fallback terminal API failed: ${e.message}", e) - return false + LOG.warn("Basic terminal activation failed: ${e.message}", e) + false } } 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 40fe81236c..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 @@ -222,7 +222,10 @@ fun RenderMessageItem( sessionId = timelineItem.sessionId, command = timelineItem.command, workingDirectory = timelineItem.workingDirectory, - ptyHandle = timelineItem.ptyHandle + ptyHandle = timelineItem.ptyHandle, + exitCode = timelineItem.exitCode, + executionTimeMs = timelineItem.executionTimeMs, + output = timelineItem.output ) } } @@ -232,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/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/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/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 = From db5b2d503605f16d9ba4a8d50444bc5046b439c9 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 19:43:29 +0800 Subject: [PATCH 48/60] refactor(idea): remove unused IdeaInputSection file Deleted obsolete IdeaInputSection to clean up the codebase. --- .../devins/idea/editor/IdeaInputSection.kt | 164 ------------------ 1 file changed, 164 deletions(-) delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt 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 856f154cf3..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaInputSection.kt +++ /dev/null @@ -1,164 +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 = {}, - onSlashClick: () -> 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() - }, - onSlashClick = { - // Insert / character and trigger slash commands - textFieldState.edit { - append("/") - } - onSlashClick() - }, - 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) - ) - ) - } -} - From 3821bc883d9407c714209383ea13b5a59bc64bf0 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 19:53:49 +0800 Subject: [PATCH 49/60] feat(mpp-idea): reorganize toolbar layout and add IdeaDevInEditorInput - Move model selector to left side of toolbar - Add MCP settings and prompt optimization buttons to right side - Create IdeaDevInEditorInput component with hybrid Swing/Compose UI - Add AutoAwesome icon for prompt optimization - Initialize PromptEnhancer and ConfigManager integration - Add placeholders for dialogs (to be implemented) --- .../devins/idea/editor/IdeaBottomToolbar.kt | 41 ++-- .../idea/editor/IdeaDevInEditorInput.kt | 201 ++++++++++++++++++ .../idea/toolwindow/IdeaComposeIcons.kt | 48 +++++ 3 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt 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 6af9bc6878..1e8ba01000 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 @@ -21,7 +21,9 @@ import org.jetbrains.jewel.ui.component.Icon * Bottom toolbar for the input section. * Provides send/stop buttons, @ trigger for agent completion, / command trigger, model selector, settings, and token info. * - * Layout: Workspace - Token Info - ModelSelector - @ Symbol - / Symbol - Settings - Send Button + * Layout: ModelSelector - Workspace - Token Info | @ Symbol - / Symbol - MCP Settings - Prompt Optimization - Send Button + * - Left side: Model configuration + * - Right side: MCP and prompt optimization * * Uses Jewel components for native IntelliJ IDEA look and feel. */ @@ -34,6 +36,7 @@ fun IdeaBottomToolbar( onAtClick: () -> Unit = {}, onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, + onPromptOptimizationClick: () -> Unit = {}, workspacePath: String? = null, totalTokens: Int? = null, // Model selector props @@ -50,12 +53,20 @@ fun IdeaBottomToolbar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // Left side: workspace and token info + // Left side: Model selector, workspace and token info Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f, fill = false) ) { + // Model selector (moved to left) + IdeaModelSelector( + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = onConfigSelect, + onConfigureClick = onConfigureClick + ) + // Workspace indicator if (!workspacePath.isNullOrEmpty()) { // Extract project name from path, handling both Unix and Windows separators @@ -117,18 +128,11 @@ fun IdeaBottomToolbar( } } - // Right side: action buttons + // Right side: action buttons (MCP and prompt optimization on the right) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - // Model selector - IdeaModelSelector( - availableConfigs = availableConfigs, - currentConfigName = currentConfigName, - onConfigSelect = onConfigSelect, - onConfigureClick = onConfigureClick - ) // @ trigger button for agent completion IconButton( @@ -157,14 +161,27 @@ fun IdeaBottomToolbar( ) } - // Settings button + // MCP Settings button (moved to right side) IconButton( onClick = onSettingsClick, modifier = Modifier.size(32.dp) ) { Icon( imageVector = IdeaComposeIcons.Settings, - contentDescription = "Settings", + contentDescription = "MCP Settings", + tint = JewelTheme.globalColors.text.normal, + modifier = Modifier.size(16.dp) + ) + } + + // Prompt Optimization button (new, on right side) + IconButton( + onClick = onPromptOptimizationClick, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = IdeaComposeIcons.AutoAwesome, + contentDescription = "Prompt Optimization", tint = JewelTheme.globalColors.text.normal, modifier = Modifier.size(16.dp) ) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt new file mode 100644 index 0000000000..4c6d8cc12c --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt @@ -0,0 +1,201 @@ +package cc.unitmesh.devins.idea.editor + +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.ui.config.ConfigManager +import cc.unitmesh.devins.workspace.WorkspaceManager +import cc.unitmesh.llm.KoogLLMService +import cc.unitmesh.llm.ModelConfig +import cc.unitmesh.llm.NamedModelConfig +import cc.unitmesh.llm.PromptEnhancer +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.project.Project +import kotlinx.coroutines.launch +import org.jetbrains.jewel.ui.component.Text +import javax.swing.JPanel +import java.awt.BorderLayout + +/** + * DevIn Editor Input for IntelliJ IDEA. + * + * Combines Swing EditorTextField with Compose toolbar for a hybrid UI. + * Features: + * - DevIn language support with syntax highlighting and completion + * - Model configuration (left side of toolbar) + * - MCP and prompt optimization (right side of toolbar) + * - Integration with IntelliJ's completion system + */ +@Composable +fun IdeaDevInEditorInput( + project: Project, + disposable: Disposable, + initialText: String = "", + placeholder: String = "Type your message or /help for commands...", + isExecuting: Boolean = false, + onSubmit: (String) -> Unit = {}, + onStopClick: () -> Unit = {}, + totalTokens: Int? = null, + modifier: Modifier = Modifier +) { + // State management + var showModelConfigDialog by remember { mutableStateOf(false) } + var showMcpConfigDialog by remember { mutableStateOf(false) } + var showPromptOptimizationDialog by remember { mutableStateOf(false) } + + var availableConfigs by remember { mutableStateOf>(emptyList()) } + var currentConfigName by remember { mutableStateOf(null) } + var currentModelConfig by remember { mutableStateOf(null) } + + var llmService by remember { mutableStateOf(null) } + var promptEnhancer by remember { mutableStateOf(null) } + + val scope = rememberCoroutineScope() + + // Create the input component + val inputComponent = remember { + IdeaDevInInput( + project = project, + listeners = emptyList(), + disposable = disposable, + showAgent = true + ).apply { + text = initialText + } + } + + // Add submit listener + DisposableEffect(Unit) { + val listener = object : IdeaInputListener { + override fun onSubmit(text: String, trigger: IdeaInputTrigger) { + onSubmit(text) + inputComponent.clearInput() + } + + override fun onTextChanged(text: String) { + // Handle text changes if needed + } + + override fun editorAdded(editor: com.intellij.openapi.editor.ex.EditorEx) { + // Handle editor added if needed + } + } + + inputComponent.addInputListener(listener) + + onDispose { + inputComponent.removeInputListener(listener) + } + } + + // Load configurations + LaunchedEffect(Unit) { + scope.launch { + try { + val configWrapper = ConfigManager.load() + availableConfigs = configWrapper.getAllConfigs() + currentConfigName = configWrapper.getActiveName() + currentModelConfig = configWrapper.getActiveModelConfig() + + // Initialize LLM service and prompt enhancer + if (currentModelConfig != null && currentModelConfig!!.isValid()) { + llmService = KoogLLMService.create(currentModelConfig!!) + + val workspace = WorkspaceManager.currentWorkspace + if (workspace != null) { + val fileSystem = workspace.fileSystem + val domainDictService = cc.unitmesh.indexer.DomainDictService(fileSystem) + promptEnhancer = PromptEnhancer(llmService!!, fileSystem, domainDictService) + } + } + } catch (e: Exception) { + println("Failed to load configurations: ${e.message}") + } + } + } + + Column(modifier = modifier) { + // Swing editor panel + SwingPanel( + factory = { + JPanel(BorderLayout()).apply { + add(inputComponent, BorderLayout.CENTER) + } + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .heightIn(min = 80.dp, max = 200.dp) + ) + + // Compose toolbar + IdeaBottomToolbar( + onSendClick = { + val text = inputComponent.text.trim() + if (text.isNotEmpty()) { + onSubmit(text) + inputComponent.clearInput() + } + }, + sendEnabled = inputComponent.text.isNotBlank(), + isExecuting = isExecuting, + onStopClick = onStopClick, + onAtClick = { + // Insert @ and trigger agent completion + inputComponent.appendText("@") + }, + onSlashClick = { + // Insert / and trigger command completion + inputComponent.appendText("/") + }, + onSettingsClick = { + showMcpConfigDialog = true + }, + onPromptOptimizationClick = { + showPromptOptimizationDialog = true + }, + workspacePath = WorkspaceManager.currentWorkspace?.rootPath, + totalTokens = totalTokens, + availableConfigs = availableConfigs, + currentConfigName = currentConfigName, + onConfigSelect = { config -> + scope.launch { + ConfigManager.setActive(config.name) + currentConfigName = config.name + currentModelConfig = config.toModelConfig() + } + }, + onConfigureClick = { + showModelConfigDialog = true + } + ) + } + + // Model Configuration Dialog + if (showModelConfigDialog) { + // TODO: Create IDEA version of model configuration dialog + // For now, just close it + showModelConfigDialog = false + } + + // MCP Configuration Dialog + if (showMcpConfigDialog) { + // TODO: Create IDEA version of MCP configuration dialog + // Should migrate from mpp-ui/ToolConfigDialog.kt + // For now, just close it + showMcpConfigDialog = false + } + + // Prompt Optimization Dialog + if (showPromptOptimizationDialog) { + // TODO: Create IDEA version of prompt optimization dialog + // Should use PromptEnhancer to optimize the current input text + // Reference: mpp-ui DevInEditorInput lines 319-349 + // For now, just close it + showPromptOptimizationDialog = false + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 2720883ad0..6e7f7782db 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1255,5 +1255,53 @@ object IdeaComposeIcons { }.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() + } + } From 3faad12c2d1a153f6ec8e24e9dc40e83ff08bac0 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 19:59:25 +0800 Subject: [PATCH 50/60] feat(mpp-idea): add DevIn language completion support to IdeaDevInInput - Add auto-completion trigger for @, /, $, : characters - Try to use DevIn file type if available for syntax highlighting - Integrate with AutoPopupController for programmatic completion - Add PsiDocumentManager for document-PSI synchronization - Fall back to plain text if DevIn language is not available --- .../devins/idea/editor/IdeaDevInInput.kt | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) 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 1e69fc6a61..46c96fcbb0 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,5 +1,6 @@ package cc.unitmesh.devins.idea.editor +import com.intellij.codeInsight.AutoPopupController import com.intellij.codeInsight.lookup.LookupManagerListener import com.intellij.lang.Language import com.intellij.openapi.Disposable @@ -17,10 +18,14 @@ import com.intellij.openapi.editor.actions.IncrementalFindAction import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.fileTypes.FileTypeManager 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 @@ -32,22 +37,32 @@ 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 ) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable { + // Try to get DevIn file type if available, otherwise use plain text + private val devInFileType: FileType? by lazy { + try { + FileTypeManager.getInstance().getFileTypeByExtension("devin") + } catch (e: Exception) { + null + } + } + private val editorListeners = EventDispatcher.create(IdeaInputListener::class.java) // Internal document listener to notify text changes @@ -191,9 +206,21 @@ class IdeaDevInInput( } } - // Create new document using EditorFactory + // Create new document with DevIn language support if available val document = ReadAction.compute { - EditorFactory.getInstance().createDocument("") + val doc = EditorFactory.getInstance().createDocument("") + + // Try to create a PsiFile with DevIn language support + devInFileType?.let { fileType -> + try { + val psiFile = PsiFileFactory.getInstance(project) + .createFileFromText("input.devin", fileType, "") + PsiDocumentManager.getInstance(project).getDocument(psiFile) ?: doc + } catch (e: Exception) { + // Fall back to plain document if DevIn language is not available + doc + } + } ?: doc } initializeDocumentListeners(document) @@ -224,11 +251,21 @@ class IdeaDevInInput( /** * Append text at the end of the document. + * If the text is a completion trigger character (@, /, $, :), auto-trigger completion. */ fun appendText(textToAppend: String) { WriteCommandAction.runWriteCommandAction(project, "Append text", "intentions.write.action", { val document = this.editor?.document ?: return@runWriteCommandAction + val currentEditor = this.editor ?: return@runWriteCommandAction + document.insertString(document.textLength, textToAppend) + currentEditor.caretModel.moveToOffset(document.textLength) + + // Auto-trigger completion for special characters + if (textToAppend in listOf("@", "/", "$", ":")) { + PsiDocumentManager.getInstance(project).commitDocument(document) + AutoPopupController.getInstance(project).autoPopupMemberLookup(currentEditor, null) + } }) } From cd8c5882f97a3b6b3910b0a42b8cbcdc8cc65040 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:07:34 +0800 Subject: [PATCH 51/60] feat(mpp-idea): add MCP configuration dialog - Create IdeaMcpConfigDialog with two tabs: Tools and MCP Servers - Implement auto-save functionality with 2 seconds delay - Add real-time JSON validation for MCP server configuration - Support incremental MCP server loading with progress indication - Use Jewel UI components (DefaultButton, OutlinedButton, Checkbox, etc.) - Integrate with IdeaDevInEditorInput component --- .../idea/editor/IdeaDevInEditorInput.kt | 7 +- .../devins/idea/editor/IdeaMcpConfigDialog.kt | 440 ++++++++++++++++++ 2 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaMcpConfigDialog.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt index 4c6d8cc12c..11b507f49d 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt @@ -183,10 +183,9 @@ fun IdeaDevInEditorInput( // MCP Configuration Dialog if (showMcpConfigDialog) { - // TODO: Create IDEA version of MCP configuration dialog - // Should migrate from mpp-ui/ToolConfigDialog.kt - // For now, just close it - showMcpConfigDialog = false + IdeaMcpConfigDialog( + onDismiss = { showMcpConfigDialog = false } + ) } // Prompt Optimization Dialog 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") + } + } + } +} + From 107c4d1bc61908045513df90eba94901ca83cb2c Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:09:18 +0800 Subject: [PATCH 52/60] feat(mpp-idea): add prompt optimization dialog - Create IdeaPromptOptimizationDialog with side-by-side comparison - Display original and enhanced prompts - Auto-enhance on dialog open using PromptEnhancer - Allow editing of enhanced text before applying - Integrate with IdeaDevInEditorInput component - Support Ctrl+P keyboard shortcut (via toolbar button) --- .../idea/editor/IdeaDevInEditorInput.kt | 14 +- .../editor/IdeaPromptOptimizationDialog.kt | 186 ++++++++++++++++++ 2 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaPromptOptimizationDialog.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt index 11b507f49d..0697305c57 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt @@ -190,11 +190,15 @@ fun IdeaDevInEditorInput( // Prompt Optimization Dialog if (showPromptOptimizationDialog) { - // TODO: Create IDEA version of prompt optimization dialog - // Should use PromptEnhancer to optimize the current input text - // Reference: mpp-ui DevInEditorInput lines 319-349 - // For now, just close it - showPromptOptimizationDialog = false + IdeaPromptOptimizationDialog( + originalText = inputComponent.text, + enhancer = promptEnhancer, + onApply = { enhancedText -> + inputComponent.text = enhancedText + showPromptOptimizationDialog = false + }, + onDismiss = { showPromptOptimizationDialog = false } + ) } } 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") + } + } + } + } +} + From cac9edc3047ec9e14f86446571d77199d3730b79 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:20:58 +0800 Subject: [PATCH 53/60] feat(mpp-idea): improve UI aesthetics and remove workspace display - Add SmartToy icon for model selector (robot/AI icon) - Make model selector blend with background using hover effects - Remove workspace/project name display (not needed in IDEA) - Add border and rounded corners to IdeaDevInEditorInput - Add padding to editor panel for better spacing - Simplify token display to compact format (e.g., '1234t') - Reduce toolbar padding for cleaner look --- .../devins/idea/editor/IdeaBottomToolbar.kt | 79 ++++--------------- .../idea/editor/IdeaDevInEditorInput.kt | 21 ++++- .../devins/idea/editor/IdeaModelSelector.kt | 61 +++++++++----- .../devins/idea/toolwindow/IdeaAgentApp.kt | 1 - .../idea/toolwindow/IdeaComposeIcons.kt | 58 ++++++++++++++ 5 files changed, 132 insertions(+), 88 deletions(-) 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 1e8ba01000..08d9efc17d 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 @@ -21,11 +21,12 @@ import org.jetbrains.jewel.ui.component.Icon * Bottom toolbar for the input section. * Provides send/stop buttons, @ trigger for agent completion, / command trigger, model selector, settings, and token info. * - * Layout: ModelSelector - Workspace - Token Info | @ Symbol - / Symbol - MCP Settings - Prompt Optimization - Send Button - * - Left side: Model configuration + * Layout: ModelSelector - Token Info | @ Symbol - / Symbol - MCP Settings - Prompt Optimization - Send Button + * - Left side: Model configuration (blends with background) * - Right side: MCP and prompt optimization * * Uses Jewel components for native IntelliJ IDEA look and feel. + * Note: Workspace/project name is not shown in IDEA version as it's already visible in the IDE. */ @Composable fun IdeaBottomToolbar( @@ -37,7 +38,6 @@ fun IdeaBottomToolbar( onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, - workspacePath: String? = null, totalTokens: Int? = null, // Model selector props availableConfigs: List = emptyList(), @@ -49,17 +49,17 @@ fun IdeaBottomToolbar( 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: Model selector, 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) ) { - // Model selector (moved to left) + // Model selector (transparent, blends with background) IdeaModelSelector( availableConfigs = availableConfigs, currentConfigName = currentConfigName, @@ -67,64 +67,15 @@ fun IdeaBottomToolbar( onConfigureClick = onConfigureClick ) - // 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 - .clip(RoundedCornerShape(4.dp)) - .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.8f)) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - imageVector = IdeaComposeIcons.Folder, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(12.dp) - ) - Text( - text = projectName, - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), - maxLines = 1 - ) - } - } - } - - // Token usage indicator + // Token usage indicator (subtle) if (totalTokens != null && totalTokens > 0) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .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) + ) + ) } } diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt index 0697305c57..e9341f30d4 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt @@ -1,9 +1,13 @@ 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.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.awt.SwingPanel +import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import cc.unitmesh.devins.ui.config.ConfigManager import cc.unitmesh.devins.workspace.WorkspaceManager @@ -15,6 +19,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.project.Project import kotlinx.coroutines.launch +import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.ui.component.Text import javax.swing.JPanel import java.awt.BorderLayout @@ -117,7 +122,17 @@ fun IdeaDevInEditorInput( } } - Column(modifier = modifier) { + // Main container with border and rounded corners + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .border( + width = 1.dp, + color = JewelTheme.globalColors.borders.normal, + shape = RoundedCornerShape(8.dp) + ) + .background(JewelTheme.globalColors.panelBackground) + ) { // Swing editor panel SwingPanel( factory = { @@ -129,8 +144,9 @@ fun IdeaDevInEditorInput( .fillMaxWidth() .weight(1f) .heightIn(min = 80.dp, max = 200.dp) + .padding(horizontal = 8.dp, vertical = 4.dp) ) - + // Compose toolbar IdeaBottomToolbar( onSendClick = { @@ -157,7 +173,6 @@ fun IdeaDevInEditorInput( onPromptOptimizationClick = { showPromptOptimizationDialog = true }, - workspacePath = WorkspaceManager.currentWorkspace?.rootPath, totalTokens = totalTokens, availableConfigs = availableConfigs, currentConfigName = currentConfigName, 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 index bd6f778d74..b4726887f8 100644 --- 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 @@ -2,6 +2,9 @@ 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 @@ -29,6 +32,7 @@ import org.jetbrains.jewel.ui.Orientation * 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( @@ -39,6 +43,8 @@ fun IdeaModelSelector( 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 } @@ -49,27 +55,42 @@ fun IdeaModelSelector( } Box(modifier = modifier) { - // Main selector button - OutlinedButton( - onClick = { expanded = true }, - modifier = Modifier.height(32.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = displayText, - style = JewelTheme.defaultTextStyle.copy(fontSize = 12.sp), - maxLines = 1 - ) - Icon( - imageVector = IdeaComposeIcons.ArrowDropDown, - contentDescription = null, - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(16.dp) + // 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 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 68f627a5ea..4b74a25dc7 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 @@ -378,7 +378,6 @@ private fun IdeaDevInInputArea( devInInput?.appendText("/") }, onSettingsClick = onSettingsClick, - workspacePath = workspacePath, totalTokens = totalTokens, availableConfigs = availableConfigs, currentConfigName = currentConfigName, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index 6e7f7782db..f326b0c61c 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1255,6 +1255,64 @@ object IdeaComposeIcons { }.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) */ From 593d4ca2e74c090fe63719f1e02ff4688c9e7430 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:33:06 +0800 Subject: [PATCH 54/60] feat(mpp-idea): add top toolbar with @ trigger and file selection - Create IdeaTopToolbar component with @ and / triggers moved from bottom - Add file selection buttons (clipboard, save, cursor, add file) - Add file chips display with remove functionality - Add new icons: ContentPaste, Save, TextFields, InsertDriveFile, Close - Simplify IdeaBottomToolbar by removing @ and / buttons - Update IdeaDevInEditorInput to use new top toolbar layout - Update IdeaAgentApp to match new toolbar API --- .../devins/idea/editor/IdeaBottomToolbar.kt | 45 +---- .../idea/editor/IdeaDevInEditorInput.kt | 47 +++-- .../devins/idea/editor/IdeaTopToolbar.kt | 189 ++++++++++++++++++ .../devins/idea/toolwindow/IdeaAgentApp.kt | 8 - .../idea/toolwindow/IdeaComposeIcons.kt | 179 +++++++++++++++++ 5 files changed, 404 insertions(+), 64 deletions(-) create mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaTopToolbar.kt 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 08d9efc17d..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 @@ -19,14 +19,13 @@ import org.jetbrains.jewel.ui.component.Icon /** * Bottom toolbar for the input section. - * Provides send/stop buttons, @ trigger for agent completion, / command trigger, model selector, settings, and token info. + * Provides send/stop buttons, model selector, settings, and token info. * - * Layout: ModelSelector - Token Info | @ Symbol - / Symbol - MCP Settings - Prompt Optimization - Send Button + * Layout: ModelSelector - Token Info | MCP Settings - Prompt Optimization - Send Button * - Left side: Model configuration (blends with background) - * - Right side: MCP and prompt optimization + * - Right side: MCP, prompt optimization, and send * - * Uses Jewel components for native IntelliJ IDEA look and feel. - * Note: Workspace/project name is not shown in IDEA version as it's already visible in the IDE. + * Note: @ and / triggers are now in the top toolbar (IdeaTopToolbar). */ @Composable fun IdeaBottomToolbar( @@ -34,8 +33,6 @@ fun IdeaBottomToolbar( sendEnabled: Boolean, isExecuting: Boolean = false, onStopClick: () -> Unit = {}, - onAtClick: () -> Unit = {}, - onSlashClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onPromptOptimizationClick: () -> Unit = {}, totalTokens: Int? = null, @@ -79,40 +76,12 @@ fun IdeaBottomToolbar( } } - // Right side: action buttons (MCP and prompt optimization on the right) + // Right side: action buttons Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { - - // @ trigger button for agent completion - IconButton( - onClick = onAtClick, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = IdeaComposeIcons.AlternateEmail, - contentDescription = "@ Agent", - tint = JewelTheme.globalColors.text.normal, - modifier = Modifier.size(18.dp) - ) - } - - // / trigger button for slash commands - IconButton( - onClick = onSlashClick, - modifier = Modifier.size(32.dp) - ) { - Text( - text = "/", - style = JewelTheme.defaultTextStyle.copy( - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - ) - } - - // MCP Settings button (moved to right side) + // MCP Settings button IconButton( onClick = onSettingsClick, modifier = Modifier.size(32.dp) @@ -125,7 +94,7 @@ fun IdeaBottomToolbar( ) } - // Prompt Optimization button (new, on right side) + // Prompt Optimization button IconButton( onClick = onPromptOptimizationClick, modifier = Modifier.size(32.dp) diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt index e9341f30d4..165d9a4b76 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt @@ -122,6 +122,9 @@ fun IdeaDevInEditorInput( } } + // State for selected files + var selectedFiles by remember { mutableStateOf>(emptyList()) } + // Main container with border and rounded corners Column( modifier = modifier @@ -133,6 +136,28 @@ fun IdeaDevInEditorInput( ) .background(JewelTheme.globalColors.panelBackground) ) { + // Top toolbar with @ trigger, file selection, etc. + IdeaTopToolbar( + onAtClick = { inputComponent.appendText("@") }, + onSlashClick = { inputComponent.appendText("/") }, + onClipboardClick = { + // TODO: Paste from clipboard + }, + onSaveClick = { + // TODO: Save to workspace + }, + onCursorClick = { + // TODO: Insert current selection + }, + onAddFileClick = { + // TODO: Show file picker popup + }, + selectedFiles = selectedFiles, + onRemoveFile = { file -> + selectedFiles = selectedFiles.filter { it != file } + } + ) + // Swing editor panel SwingPanel( factory = { @@ -147,7 +172,7 @@ fun IdeaDevInEditorInput( .padding(horizontal = 8.dp, vertical = 4.dp) ) - // Compose toolbar + // Bottom toolbar with model selector and send button IdeaBottomToolbar( onSendClick = { val text = inputComponent.text.trim() @@ -159,20 +184,8 @@ fun IdeaDevInEditorInput( sendEnabled = inputComponent.text.isNotBlank(), isExecuting = isExecuting, onStopClick = onStopClick, - onAtClick = { - // Insert @ and trigger agent completion - inputComponent.appendText("@") - }, - onSlashClick = { - // Insert / and trigger command completion - inputComponent.appendText("/") - }, - onSettingsClick = { - showMcpConfigDialog = true - }, - onPromptOptimizationClick = { - showPromptOptimizationDialog = true - }, + onSettingsClick = { showMcpConfigDialog = true }, + onPromptOptimizationClick = { showPromptOptimizationDialog = true }, totalTokens = totalTokens, availableConfigs = availableConfigs, currentConfigName = currentConfigName, @@ -183,9 +196,7 @@ fun IdeaDevInEditorInput( currentModelConfig = config.toModelConfig() } }, - onConfigureClick = { - showModelConfigDialog = true - } + onConfigureClick = { showModelConfigDialog = true } ) } 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/toolwindow/IdeaAgentApp.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt index 4b74a25dc7..904f6ed06d 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 @@ -369,14 +369,6 @@ private fun IdeaDevInInputArea( sendEnabled = inputText.isNotBlank() && !isProcessing, isExecuting = isProcessing, onStopClick = onAbort, - onAtClick = { - devInInput?.appendText("@") - onAtClick() - }, - onSlashClick = { - // Insert / at current cursor position to trigger slash commands - devInInput?.appendText("/") - }, onSettingsClick = onSettingsClick, totalTokens = totalTokens, availableConfigs = availableConfigs, diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt index f326b0c61c..5ffb0dbc7f 100644 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaComposeIcons.kt @@ -1361,5 +1361,184 @@ object IdeaComposeIcons { }.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() + } + } From a7ff205dd4e23525d0484daaab9af96f9ac2e084 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:49:35 +0800 Subject: [PATCH 55/60] refactor(mpp-idea): remove unused editor input class Deleted IdeaDevInEditorInput.kt as it is no longer needed. --- .../idea/editor/IdeaDevInEditorInput.kt | 230 ------------------ 1 file changed, 230 deletions(-) delete mode 100644 mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt deleted file mode 100644 index 165d9a4b76..0000000000 --- a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInEditorInput.kt +++ /dev/null @@ -1,230 +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.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.SwingPanel -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import cc.unitmesh.devins.ui.config.ConfigManager -import cc.unitmesh.devins.workspace.WorkspaceManager -import cc.unitmesh.llm.KoogLLMService -import cc.unitmesh.llm.ModelConfig -import cc.unitmesh.llm.NamedModelConfig -import cc.unitmesh.llm.PromptEnhancer -import com.intellij.openapi.Disposable -import com.intellij.openapi.editor.event.DocumentListener -import com.intellij.openapi.project.Project -import kotlinx.coroutines.launch -import org.jetbrains.jewel.foundation.theme.JewelTheme -import org.jetbrains.jewel.ui.component.Text -import javax.swing.JPanel -import java.awt.BorderLayout - -/** - * DevIn Editor Input for IntelliJ IDEA. - * - * Combines Swing EditorTextField with Compose toolbar for a hybrid UI. - * Features: - * - DevIn language support with syntax highlighting and completion - * - Model configuration (left side of toolbar) - * - MCP and prompt optimization (right side of toolbar) - * - Integration with IntelliJ's completion system - */ -@Composable -fun IdeaDevInEditorInput( - project: Project, - disposable: Disposable, - initialText: String = "", - placeholder: String = "Type your message or /help for commands...", - isExecuting: Boolean = false, - onSubmit: (String) -> Unit = {}, - onStopClick: () -> Unit = {}, - totalTokens: Int? = null, - modifier: Modifier = Modifier -) { - // State management - var showModelConfigDialog by remember { mutableStateOf(false) } - var showMcpConfigDialog by remember { mutableStateOf(false) } - var showPromptOptimizationDialog by remember { mutableStateOf(false) } - - var availableConfigs by remember { mutableStateOf>(emptyList()) } - var currentConfigName by remember { mutableStateOf(null) } - var currentModelConfig by remember { mutableStateOf(null) } - - var llmService by remember { mutableStateOf(null) } - var promptEnhancer by remember { mutableStateOf(null) } - - val scope = rememberCoroutineScope() - - // Create the input component - val inputComponent = remember { - IdeaDevInInput( - project = project, - listeners = emptyList(), - disposable = disposable, - showAgent = true - ).apply { - text = initialText - } - } - - // Add submit listener - DisposableEffect(Unit) { - val listener = object : IdeaInputListener { - override fun onSubmit(text: String, trigger: IdeaInputTrigger) { - onSubmit(text) - inputComponent.clearInput() - } - - override fun onTextChanged(text: String) { - // Handle text changes if needed - } - - override fun editorAdded(editor: com.intellij.openapi.editor.ex.EditorEx) { - // Handle editor added if needed - } - } - - inputComponent.addInputListener(listener) - - onDispose { - inputComponent.removeInputListener(listener) - } - } - - // Load configurations - LaunchedEffect(Unit) { - scope.launch { - try { - val configWrapper = ConfigManager.load() - availableConfigs = configWrapper.getAllConfigs() - currentConfigName = configWrapper.getActiveName() - currentModelConfig = configWrapper.getActiveModelConfig() - - // Initialize LLM service and prompt enhancer - if (currentModelConfig != null && currentModelConfig!!.isValid()) { - llmService = KoogLLMService.create(currentModelConfig!!) - - val workspace = WorkspaceManager.currentWorkspace - if (workspace != null) { - val fileSystem = workspace.fileSystem - val domainDictService = cc.unitmesh.indexer.DomainDictService(fileSystem) - promptEnhancer = PromptEnhancer(llmService!!, fileSystem, domainDictService) - } - } - } catch (e: Exception) { - println("Failed to load configurations: ${e.message}") - } - } - } - - // State for selected files - var selectedFiles by remember { mutableStateOf>(emptyList()) } - - // Main container with border and rounded corners - Column( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .border( - width = 1.dp, - color = JewelTheme.globalColors.borders.normal, - shape = RoundedCornerShape(8.dp) - ) - .background(JewelTheme.globalColors.panelBackground) - ) { - // Top toolbar with @ trigger, file selection, etc. - IdeaTopToolbar( - onAtClick = { inputComponent.appendText("@") }, - onSlashClick = { inputComponent.appendText("/") }, - onClipboardClick = { - // TODO: Paste from clipboard - }, - onSaveClick = { - // TODO: Save to workspace - }, - onCursorClick = { - // TODO: Insert current selection - }, - onAddFileClick = { - // TODO: Show file picker popup - }, - selectedFiles = selectedFiles, - onRemoveFile = { file -> - selectedFiles = selectedFiles.filter { it != file } - } - ) - - // Swing editor panel - SwingPanel( - factory = { - JPanel(BorderLayout()).apply { - add(inputComponent, BorderLayout.CENTER) - } - }, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .heightIn(min = 80.dp, max = 200.dp) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) - - // Bottom toolbar with model selector and send button - IdeaBottomToolbar( - onSendClick = { - val text = inputComponent.text.trim() - if (text.isNotEmpty()) { - onSubmit(text) - inputComponent.clearInput() - } - }, - sendEnabled = inputComponent.text.isNotBlank(), - isExecuting = isExecuting, - onStopClick = onStopClick, - onSettingsClick = { showMcpConfigDialog = true }, - onPromptOptimizationClick = { showPromptOptimizationDialog = true }, - totalTokens = totalTokens, - availableConfigs = availableConfigs, - currentConfigName = currentConfigName, - onConfigSelect = { config -> - scope.launch { - ConfigManager.setActive(config.name) - currentConfigName = config.name - currentModelConfig = config.toModelConfig() - } - }, - onConfigureClick = { showModelConfigDialog = true } - ) - } - - // Model Configuration Dialog - if (showModelConfigDialog) { - // TODO: Create IDEA version of model configuration dialog - // For now, just close it - showModelConfigDialog = false - } - - // MCP Configuration Dialog - if (showMcpConfigDialog) { - IdeaMcpConfigDialog( - onDismiss = { showMcpConfigDialog = false } - ) - } - - // Prompt Optimization Dialog - if (showPromptOptimizationDialog) { - IdeaPromptOptimizationDialog( - originalText = inputComponent.text, - enhancer = promptEnhancer, - onApply = { enhancedText -> - inputComponent.text = enhancedText - showPromptOptimizationDialog = false - }, - onDismiss = { showPromptOptimizationDialog = false } - ) - } -} - From eeda94685401e94a84348252934b8ebe8e498778 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:52:49 +0800 Subject: [PATCH 56/60] feat(mpp-idea): add resizable split pane between content and input area - Use IdeaVerticalResizableSplitPane to separate timeline content and input area - Allow dynamic resizing of input area (30% to 90% of available space) - Initial split ratio set to 75% for content, 25% for input - Apply resizable layout to CODING, LOCAL_CHAT, and REMOTE agent types - CODE_REVIEW and KNOWLEDGE modes use fixed layout (no input area) --- .../devins/idea/toolwindow/IdeaAgentApp.kt | 171 +++++++++--------- 1 file changed, 88 insertions(+), 83 deletions(-) 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 904f6ed06d..2de94d6878 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 @@ -16,6 +16,7 @@ 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.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 @@ -147,32 +148,92 @@ 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.LOCAL_CHAT -> { - IdeaTimelineContent( - timeline = timeline, - streamingOutput = streamingOutput, - listState = listState, - project = project - ) - } - AgentType.REMOTE -> { - remoteAgentViewModel?.let { vm -> - IdeaRemoteAgentContent( - viewModel = vm, + // 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, - onProjectIdChange = { remoteProjectId = it }, - onGitUrlChange = { remoteGitUrl = it } + project = project ) - } ?: IdeaEmptyStateMessage("Loading Remote Agent...") - } - AgentType.CODE_REVIEW -> { + }, + 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) } + ) + } + ) + } ?: IdeaEmptyStateMessage("Loading Remote Agent...") + } + AgentType.CODE_REVIEW -> { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { codeReviewViewModel?.let { vm -> IdeaCodeReviewContent( viewModel = vm, @@ -180,7 +241,9 @@ fun IdeaAgentApp( ) } ?: IdeaEmptyStateMessage("Loading Code Review...") } - AgentType.KNOWLEDGE -> { + } + AgentType.KNOWLEDGE -> { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { knowledgeViewModel?.let { vm -> IdeaKnowledgeContent(viewModel = vm) } ?: IdeaEmptyStateMessage("Loading Knowledge Agent...") @@ -188,64 +251,6 @@ fun IdeaAgentApp( } } - Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) - - // Input area (only for chat-based modes) - if (currentAgentType == AgentType.CODING || currentAgentType == AgentType.LOCAL_CHAT) { - IdeaDevInInputArea( - 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 - }, - availableConfigs = availableConfigs, - currentConfigName = currentConfigName, - onConfigSelect = { config -> - viewModel.setActiveConfig(config.name) - }, - onConfigureClick = { viewModel.setShowConfigDialog(true) } - ) - } - - // Remote agent input area - if (currentAgentType == AgentType.REMOTE) { - remoteAgentViewModel?.let { remoteVm -> - val remoteIsExecuting by remoteVm.isExecuting.collectAsState() - val remoteIsConnected by remoteVm.isConnected.collectAsState() - - 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) } - ) - } - } - // Tool loading status bar IdeaToolLoadingStatusBar( viewModel = viewModel, From 697d4494589f0694b6fb9030fc8fe616efc01cdd Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 20:57:03 +0800 Subject: [PATCH 57/60] fix(mpp-idea): make EditorTextField resize with split pane - Change SwingPanel from fixed height (120.dp) to weight(1f) for dynamic sizing - Change Column modifier from fillMaxWidth to fillMaxSize - Remove fixed preferredSize from JPanel wrapper - EditorTextField now properly resizes when dragging the split pane divider --- .../unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 2de94d6878..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 @@ -307,13 +307,13 @@ private fun IdeaDevInInputArea( 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, @@ -349,11 +349,11 @@ private fun IdeaDevInInputArea( 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 -> From a989c1f416438bc066db791cbf01aea33e12f465 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 21:09:57 +0800 Subject: [PATCH 58/60] fix(mpp-idea): position model selector popup above to avoid covering input - Add alignment = BottomStart to Popup to make it appear above the selector - This prevents the dropdown from covering the input area when opened --- .../cc/unitmesh/devins/idea/editor/IdeaModelSelector.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index b4726887f8..5cb2da0250 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -93,9 +94,10 @@ fun IdeaModelSelector( ) } - // Dropdown popup + // Dropdown popup - positioned above the selector to avoid covering input area if (expanded) { Popup( + alignment = ComposeAlignment.BottomStart, onDismissRequest = { expanded = false }, properties = PopupProperties(focusable = true) ) { From 7106899aad7840ed88af090f5efa5674317ad031 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 22:06:59 +0800 Subject: [PATCH 59/60] feat(mpp-idea): add DevIn language support and migrate to Gson Integrates DevIn language for completion and syntax highlighting, replaces kotlinx.serialization with Gson for JSON handling, updates dependencies, and improves Gradle configuration. --- mpp-idea/build.gradle.kts | 30 +++++++++-- mpp-idea/gradle.properties | 15 ++++++ mpp-idea/settings.gradle.kts | 9 ++-- .../devins/idea/editor/IdeaDevInInput.kt | 53 +++++-------------- .../remote/IdeaRemoteAgentClient.kt | 20 +++---- .../src/main/resources/META-INF/plugin.xml | 11 +++- 6 files changed, 76 insertions(+), 62 deletions(-) create mode 100644 mpp-idea/gradle.properties diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index 7df79f882f..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" @@ -102,15 +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") @@ -128,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 c8a23317c4..d7a203841c 100644 --- a/mpp-idea/settings.gradle.kts +++ b/mpp-idea/settings.gradle.kts @@ -10,9 +10,8 @@ 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" } } @@ -24,6 +23,7 @@ pluginManagement { // - 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 Maven coordinates with project dependencies @@ -31,6 +31,9 @@ includeBuild("..") { 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/editor/IdeaDevInInput.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/editor/IdeaDevInInput.kt index 46c96fcbb0..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,8 +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 @@ -10,7 +11,6 @@ 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 @@ -18,8 +18,6 @@ import com.intellij.openapi.editor.actions.IncrementalFindAction import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.editor.ex.EditorEx -import com.intellij.openapi.fileTypes.FileType -import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.project.Project @@ -54,15 +52,6 @@ class IdeaDevInInput( private val showAgent: Boolean = true ) : EditorTextField(project, FileTypes.PLAIN_TEXT), Disposable { - // Try to get DevIn file type if available, otherwise use plain text - private val devInFileType: FileType? by lazy { - try { - FileTypeManager.getInstance().getFileTypeByExtension("devin") - } catch (e: Exception) { - null - } - } - private val editorListeners = EventDispatcher.create(IdeaInputListener::class.java) // Internal document listener to notify text changes @@ -206,25 +195,18 @@ class IdeaDevInInput( } } - // Create new document with DevIn language support if available + // Create new document with DevIn language support + val id = UUID.randomUUID() val document = ReadAction.compute { - val doc = EditorFactory.getInstance().createDocument("") - - // Try to create a PsiFile with DevIn language support - devInFileType?.let { fileType -> - try { - val psiFile = PsiFileFactory.getInstance(project) - .createFileFromText("input.devin", fileType, "") - PsiDocumentManager.getInstance(project).getDocument(psiFile) ?: doc - } catch (e: Exception) { - // Fall back to plain document if DevIn language is not available - doc - } - } ?: doc + 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) { @@ -251,21 +233,12 @@ class IdeaDevInInput( /** * Append text at the end of the document. - * If the text is a completion trigger character (@, /, $, :), auto-trigger completion. + * 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 - val currentEditor = this.editor ?: return@runWriteCommandAction - - document.insertString(document.textLength, textToAppend) - currentEditor.caretModel.moveToOffset(document.textLength) - - // Auto-trigger completion for special characters - if (textToAppend in listOf("@", "/", "$", ":")) { - PsiDocumentManager.getInstance(project).commitDocument(document) - AutoPopupController.getInstance(project).autoPopupMemberLookup(currentEditor, null) - } + InsertUtil.insertStringAndSaveChange(project, textToAppend, document, document.textLength, false) }) } 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 index 8e2149b0c4..e6ba3bc69d 100644 --- 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 @@ -1,6 +1,8 @@ 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.* @@ -10,8 +12,6 @@ import io.ktor.http.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.mapNotNull -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import kotlin.time.Duration.Companion.seconds /** @@ -45,10 +45,7 @@ class IdeaRemoteAgentClient( } } - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - } + private val gson = Gson() /** * Health check to verify server is running @@ -58,7 +55,7 @@ class IdeaRemoteAgentClient( if (!response.status.isSuccess()) { throw RemoteAgentException("Health check failed: ${response.status}") } - return json.decodeFromString(response.bodyAsText()) + return gson.fromJson(response.bodyAsText(), HealthResponse::class.java) } /** @@ -69,7 +66,7 @@ class IdeaRemoteAgentClient( if (!response.status.isSuccess()) { throw RemoteAgentException("Failed to fetch projects: ${response.status}") } - return json.decodeFromString(response.bodyAsText()) + return gson.fromJson(response.bodyAsText(), ProjectListResponse::class.java) } /** @@ -83,7 +80,7 @@ class IdeaRemoteAgentClient( request = { method = HttpMethod.Post contentType(ContentType.Application.Json) - setBody(json.encodeToString(RemoteAgentRequest.serializer(), request)) + setBody(gson.toJson(request)) } ) { // Check HTTP status before processing SSE events @@ -118,7 +115,6 @@ class IdeaRemoteAgentClient( /** * Request/Response Data Classes */ -@Serializable data class RemoteAgentRequest( val projectId: String, val task: String, @@ -129,7 +125,6 @@ data class RemoteAgentRequest( val password: String? = null ) -@Serializable data class LLMConfig( val provider: String, val modelName: String, @@ -137,12 +132,10 @@ data class LLMConfig( val baseUrl: String? = null ) -@Serializable data class HealthResponse( val status: String ) -@Serializable data class ProjectInfo( val id: String, val name: String, @@ -150,7 +143,6 @@ data class ProjectInfo( val description: String ) -@Serializable data class ProjectListResponse( val projects: List ) diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index 64855cd9a8..27a27e17d8 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -1,22 +1,29 @@ - + cc.unitmesh.devins.idea AutoDev Compose UI UnitMesh + + + + messages.AutoDevIdeaBundle + + + From fffab931aaef95617cfaf33f91ed41344caffb60 Mon Sep 17 00:00:00 2001 From: Phodal Huang Date: Mon, 1 Dec 2025 22:17:54 +0800 Subject: [PATCH 60/60] feat(mpp-idea): add extension points for devins-lang module Define required extension points in plugin.xml to support devins-lang integration. --- .../src/main/resources/META-INF/plugin.xml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/mpp-idea/src/main/resources/META-INF/plugin.xml b/mpp-idea/src/main/resources/META-INF/plugin.xml index 27a27e17d8..f3c963d3f7 100644 --- a/mpp-idea/src/main/resources/META-INF/plugin.xml +++ b/mpp-idea/src/main/resources/META-INF/plugin.xml @@ -21,6 +21,37 @@ messages.AutoDevIdeaBundle + + + + + + + + + + + + + + + + +