Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions mpp-idea/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import org.jetbrains.intellij.platform.gradle.TestFrameworkType

plugins {
id("java")
kotlin("jvm") version "2.2.0"
id("org.jetbrains.intellij.platform") version "2.10.2"
kotlin("plugin.compose") version "2.2.0"
kotlin("plugin.serialization") version "2.2.0"
}

group = "cc.unitmesh.devins"
version = project.findProperty("mppVersion") as String? ?: "0.3.2"

kotlin {
jvmToolchain(21)

compilerOptions {
freeCompilerArgs.addAll(
listOf(
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
)
)
}
}

repositories {
mavenCentral()

intellijPlatform {
defaultRepositories()
}
google()
}

dependencies {
// Kotlinx serialization for JSON
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")

testImplementation(kotlin("test"))

intellijPlatform {
// Target IntelliJ IDEA 2025.2+ for Compose support
create("IC", "2025.2.1")

bundledPlugins("com.intellij.java")

// Compose support dependencies (bundled in IDEA 252+)
bundledModules(
"intellij.libraries.skiko",
"intellij.libraries.compose.foundation.desktop",
"intellij.platform.jewel.foundation",
"intellij.platform.jewel.ui",
"intellij.platform.jewel.ideLafBridge",
"intellij.platform.compose"
)

testFramework(TestFrameworkType.Platform)
}
}

intellijPlatform {
pluginConfiguration {
name = "AutoDev Compose UI"
version = project.findProperty("mppVersion") as String? ?: "0.3.2"

ideaVersion {
sinceBuild = "252"
}
}

buildSearchableOptions = false
instrumentCode = false
}

tasks {
test {
useJUnitPlatform()
}
}
16 changes: 16 additions & 0 deletions mpp-idea/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
rootProject.name = "mpp-idea"

pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
23 changes: 23 additions & 0 deletions mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/AutoDevIcons.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cc.unitmesh.devins.idea

import com.intellij.openapi.util.IconLoader
import javax.swing.Icon

/**
* Icon provider for AutoDev Compose module.
* Icons are loaded from resources for use in toolbars, tool windows, etc.
*/
object AutoDevIcons {
/**
* Tool window icon (13x13 for tool window, 16x16 for actions)
*/
@JvmField
val ToolWindow: Icon = IconLoader.getIcon("/icons/autodev-toolwindow.svg", AutoDevIcons::class.java)

/**
* Main AutoDev icon
*/
@JvmField
val AutoDev: Icon = IconLoader.getIcon("/icons/autodev.svg", AutoDevIcons::class.java)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cc.unitmesh.devins.idea.services

import com.intellij.openapi.components.Service
import com.intellij.openapi.components.Service.Level
import com.intellij.platform.util.coroutines.childScope
import kotlinx.coroutines.CoroutineScope

/**
* A service-level class that provides and manages coroutine scopes for a given project.
*
* @constructor Initializes the [CoroutineScopeHolder] with a project-wide coroutine scope.
* @param projectWideCoroutineScope A [CoroutineScope] defining the lifecycle of project-wide coroutines.
*/
@Service(Level.PROJECT)
class CoroutineScopeHolder(private val projectWideCoroutineScope: CoroutineScope) {
/**
* Creates a new coroutine scope as a child of the project-wide coroutine scope with the specified name.
*
* @param name The name for the newly created coroutine scope.
* @return a scope with a Job which parent is the Job of projectWideCoroutineScope scope.
*
* The returned scope can be completed only by cancellation.
* projectWideCoroutineScope scope will cancel the returned scope when canceled.
* If the child scope has a narrower lifecycle than projectWideCoroutineScope scope,
* then it should be canceled explicitly when not needed,
* otherwise, it will continue to live in the Job hierarchy until termination of the CoroutineScopeHolder service.
*/
@Suppress("UnstableApiUsage")
fun createScope(name: String): CoroutineScope = projectWideCoroutineScope.childScope(name)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
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.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.text.font.FontWeight
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.*
import org.jetbrains.jewel.ui.theme.defaultBannerStyle

/**
* Main Compose application for AutoDev Chat.
*
* Uses Jewel theme for native IntelliJ IDEA integration.
*/
@Composable
fun AutoDevChatApp(viewModel: AutoDevChatViewModel) {
val messages by viewModel.chatMessages.collectAsState()
val inputState by viewModel.inputState.collectAsState()
val listState = rememberLazyListState()

// Auto-scroll to bottom when new messages arrive
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(messages.lastIndex)
}
}

Column(
modifier = Modifier
.fillMaxSize()
.background(JewelTheme.globalColors.panelBackground)
) {
// Header
ChatHeader(
onNewConversation = { viewModel.onNewConversation() }
)

Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp))

// Message list
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
if (messages.isEmpty()) {
EmptyStateMessage()
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(messages, key = { it.id }) { message ->
MessageBubble(message)
}
}
}
}

Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp))

// Input area
ChatInput(
inputState = inputState,
onInputChanged = { viewModel.onInputChanged(it) },
onSend = { viewModel.onSendMessage() },
onAbort = { viewModel.onAbortMessage() }
)
}
}

@Composable
private fun ChatHeader(
onNewConversation: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "AutoDev Chat",
style = JewelTheme.defaultTextStyle.copy(
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
)

IconButton(onClick = onNewConversation) {
Text("+", style = JewelTheme.defaultTextStyle)
}
}
}

@Composable
private fun EmptyStateMessage() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Start a conversation with your AI Assistant!",
style = JewelTheme.defaultTextStyle.copy(
fontSize = 16.sp,
color = JewelTheme.globalColors.text.info
)
)
}
}

@Composable
private fun MessageBubble(message: ChatMessage) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start
) {
Box(
modifier = Modifier
.widthIn(max = 300.dp)
.background(
if (message.isUser)
JewelTheme.defaultBannerStyle.information.colors.background.copy(alpha = 0.75f)
else
JewelTheme.globalColors.panelBackground
)
.padding(8.dp)
) {
Text(
text = message.content,
style = JewelTheme.defaultTextStyle
)
}
}
}

@Composable
private fun ChatInput(
inputState: MessageInputState,
onInputChanged: (String) -> Unit,
onSend: () -> Unit,
onAbort: () -> Unit
) {
val textFieldState = rememberTextFieldState()
val isSending = inputState is MessageInputState.Sending

LaunchedEffect(Unit) {
snapshotFlow { textFieldState.text.toString() }
.distinctUntilChanged()
.collect { onInputChanged(it) }
}

Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
state = textFieldState,
placeholder = { Text("Type your message...") },
modifier = Modifier
.weight(1f)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown && !isSending) {
onSend()
textFieldState.edit { replace(0, length, "") }
true
} else {
false
}
},
enabled = !isSending
)

if (isSending) {
DefaultButton(onClick = onAbort) {
Text("Stop")
}
} else {
DefaultButton(
onClick = {
onSend()
textFieldState.edit { replace(0, length, "") }
},
enabled = inputState is MessageInputState.Enabled
) {
Text("Send")
}
}
}
}

Loading
Loading