Skip to content

Commit 5616302

Browse files
committed
feat(idea): add ACP client integration (stdio)
Implements ACP client for the IDEA plugin and surfaces it under Remote -> ACP, streaming session updates into the existing timeline renderer. Refs: #535
1 parent 5a1093f commit 5616302

File tree

6 files changed

+1084
-34
lines changed

6 files changed

+1084
-34
lines changed

mpp-idea/build.gradle.kts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,23 @@ project(":") {
396396
// Note: We use Dispatchers.EDT from IntelliJ Platform instead of Dispatchers.Swing
397397
compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
398398

399+
// ===== ACP (Agent Client Protocol) =====
400+
// Local ACP agent integration uses JSON-RPC over stdio.
401+
// Exclude kotlinx deps to avoid conflicts with IntelliJ's bundled versions.
402+
implementation("com.agentclientprotocol:acp:0.10.5") {
403+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")
404+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core-jvm")
405+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json")
406+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-json-jvm")
407+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core")
408+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-serialization-core-jvm")
409+
// We provide kotlinx-io explicitly below.
410+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-io-core")
411+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-io-core-jvm")
412+
}
413+
// ACP stdio transport uses kotlinx-io (not bundled by IntelliJ).
414+
implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.8.0")
415+
399416
// mpp-core dependency for root project - use published artifact
400417
implementation("cc.unitmesh:mpp-core:${prop("mppVersion")}") {
401418
// Exclude Compose dependencies from mpp-core as well

mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ import com.intellij.util.xmlb.XmlSerializerUtil
1515
class AutoDevSettingsState : PersistentStateComponent<AutoDevSettingsState> {
1616
var delaySeconds = ""
1717

18+
// ===== ACP (Agent Client Protocol) integration =====
19+
/**
20+
* ACP agent command to run (local process).
21+
* Example: `node`, `python`, `autodev-agent`, etc.
22+
*/
23+
var acpCommand = ""
24+
25+
/**
26+
* ACP agent args as a single string (will be parsed into argv).
27+
* Example: `path/to/agent.js --stdio`.
28+
*/
29+
var acpArgs = ""
30+
31+
/**
32+
* ACP agent environment variables in "KEY=VALUE" lines.
33+
*/
34+
var acpEnv = ""
35+
1836
// Legacy fields - kept for backward compatibility but deprecated
1937
@Deprecated("Use defaultModelId instead")
2038
var customOpenAiHost = ""

mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/IdeaAgentApp.kt

Lines changed: 89 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,14 @@ import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewContent
1515
import cc.unitmesh.devins.idea.toolwindow.codereview.IdeaCodeReviewViewModel
1616
import cc.unitmesh.devins.idea.components.header.IdeaAgentTabsHeader
1717
import cc.unitmesh.devins.idea.components.IdeaVerticalResizableSplitPane
18+
import cc.unitmesh.devins.idea.toolwindow.acp.IdeaAcpAgentContent
19+
import cc.unitmesh.devins.idea.toolwindow.acp.IdeaAcpAgentViewModel
1820
import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeContent
1921
import cc.unitmesh.devins.idea.toolwindow.knowledge.IdeaKnowledgeViewModel
2022
import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentContent
2123
import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteAgentViewModel
24+
import cc.unitmesh.devins.idea.toolwindow.remote.IdeaRemoteModeSelector
25+
import cc.unitmesh.devins.idea.toolwindow.remote.RemoteAgentMode
2226
import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId
2327
import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditContent
2428
import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditViewModel
@@ -130,6 +134,9 @@ fun IdeaAgentApp(
130134
// Remote Agent ViewModel (created lazily when needed)
131135
var remoteAgentViewModel by remember { mutableStateOf<IdeaRemoteAgentViewModel?>(null) }
132136

137+
// ACP Agent ViewModel (created lazily when needed)
138+
var acpAgentViewModel by remember { mutableStateOf<IdeaAcpAgentViewModel?>(null) }
139+
133140
// WebEdit ViewModel (created lazily when needed)
134141
var webEditViewModel by remember { mutableStateOf<IdeaWebEditViewModel?>(null) }
135142

@@ -164,30 +171,28 @@ fun IdeaAgentApp(
164171
serverUrl = "http://localhost:8080"
165172
)
166173
}
174+
if (currentAgentType == AgentType.REMOTE && acpAgentViewModel == null) {
175+
acpAgentViewModel = IdeaAcpAgentViewModel(project, coroutineScope)
176+
}
167177
if (currentAgentType == AgentType.WEB_EDIT && webEditViewModel == null) {
168178
webEditViewModel = IdeaWebEditViewModel(project, coroutineScope)
169179
}
170180
}
171181

172-
// Dispose ViewModels when leaving their tabs
173-
DisposableEffect(currentAgentType) {
182+
// Dispose ViewModels when the tool window is disposed.
183+
// Keeping them alive across tab switches avoids reconnect churn and prevents leaks.
184+
DisposableEffect(Unit) {
174185
onDispose {
175-
if (currentAgentType != AgentType.CODE_REVIEW) {
176-
codeReviewViewModel?.dispose()
177-
codeReviewViewModel = null
178-
}
179-
if (currentAgentType != AgentType.KNOWLEDGE) {
180-
knowledgeViewModel?.dispose()
181-
knowledgeViewModel = null
182-
}
183-
if (currentAgentType != AgentType.REMOTE) {
184-
remoteAgentViewModel?.dispose()
185-
remoteAgentViewModel = null
186-
}
187-
if (currentAgentType != AgentType.WEB_EDIT) {
188-
webEditViewModel?.dispose()
189-
webEditViewModel = null
190-
}
186+
codeReviewViewModel?.dispose()
187+
knowledgeViewModel?.dispose()
188+
remoteAgentViewModel?.dispose()
189+
acpAgentViewModel?.dispose()
190+
webEditViewModel?.dispose()
191+
codeReviewViewModel = null
192+
knowledgeViewModel = null
193+
remoteAgentViewModel = null
194+
acpAgentViewModel = null
195+
webEditViewModel = null
191196
}
192197
}
193198

@@ -306,41 +311,89 @@ fun IdeaAgentApp(
306311
)
307312
}
308313
AgentType.REMOTE -> {
309-
remoteAgentViewModel?.let { remoteVm ->
310-
// Use manual state collection for remote agent states
314+
val remoteVm = remoteAgentViewModel
315+
val acpVm = acpAgentViewModel
316+
if (remoteVm != null && acpVm != null) {
317+
var remoteMode by remember { mutableStateOf(RemoteAgentMode.SERVER) }
311318
var remoteIsExecuting by remember { mutableStateOf(false) }
319+
var acpIsExecuting by remember { mutableStateOf(false) }
312320

313321
IdeaLaunchedEffect(remoteVm, project = project) {
314322
remoteVm.isExecuting.collect { remoteIsExecuting = it }
315323
}
324+
IdeaLaunchedEffect(acpVm, project = project) {
325+
acpVm.isExecuting.collect { acpIsExecuting = it }
326+
}
327+
328+
val isRemoteProcessing = remoteMode == RemoteAgentMode.SERVER && remoteIsExecuting
329+
val isAcpProcessing = remoteMode == RemoteAgentMode.ACP && acpIsExecuting
316330

317331
IdeaVerticalResizableSplitPane(
318332
modifier = Modifier.fillMaxWidth().weight(1f),
319333
initialSplitRatio = 0.75f,
320334
minRatio = 0.3f,
321335
maxRatio = 0.9f,
322336
top = {
323-
IdeaRemoteAgentContent(
324-
viewModel = remoteVm,
325-
listState = listState,
326-
onProjectIdChange = { remoteProjectId = it },
327-
onGitUrlChange = { remoteGitUrl = it }
328-
)
337+
Column(modifier = Modifier.fillMaxSize()) {
338+
Row(
339+
modifier = Modifier
340+
.fillMaxWidth()
341+
.padding(horizontal = 12.dp, vertical = 8.dp),
342+
horizontalArrangement = Arrangement.Start
343+
) {
344+
IdeaRemoteModeSelector(
345+
mode = remoteMode,
346+
onModeChange = { remoteMode = it }
347+
)
348+
}
349+
Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp))
350+
351+
when (remoteMode) {
352+
RemoteAgentMode.SERVER -> {
353+
IdeaRemoteAgentContent(
354+
viewModel = remoteVm,
355+
listState = listState,
356+
onProjectIdChange = { remoteProjectId = it },
357+
onGitUrlChange = { remoteGitUrl = it },
358+
modifier = Modifier.fillMaxSize()
359+
)
360+
}
361+
RemoteAgentMode.ACP -> {
362+
IdeaAcpAgentContent(
363+
viewModel = acpVm,
364+
listState = listState,
365+
modifier = Modifier.fillMaxSize()
366+
)
367+
}
368+
}
369+
}
329370
},
330371
bottom = {
331372
IdeaDevInInputArea(
332373
project = project,
333374
parentDisposable = viewModel,
334-
isProcessing = remoteIsExecuting,
375+
isProcessing = isRemoteProcessing || isAcpProcessing,
335376
onSend = { task ->
336-
val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl)
337-
if (effectiveProjectId.isNotBlank()) {
338-
remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl)
339-
} else {
340-
remoteVm.renderer.renderError("Please provide a project or Git URL")
377+
when (remoteMode) {
378+
RemoteAgentMode.SERVER -> {
379+
val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl)
380+
if (effectiveProjectId.isNotBlank()) {
381+
remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl)
382+
} else {
383+
remoteVm.renderer.renderError("Please provide a project or Git URL")
384+
}
385+
}
386+
RemoteAgentMode.ACP -> {
387+
acpVm.sendMessage(task)
388+
}
389+
}
390+
},
391+
onAbort = {
392+
when (remoteMode) {
393+
RemoteAgentMode.SERVER -> remoteVm.cancelTask()
394+
RemoteAgentMode.ACP -> acpVm.cancelTask()
341395
}
342396
},
343-
onAbort = { remoteVm.cancelTask() },
344397
workspacePath = project.basePath,
345398
totalTokens = null,
346399
onAtClick = {},
@@ -355,7 +408,9 @@ fun IdeaAgentApp(
355408
)
356409
}
357410
)
358-
} ?: IdeaEmptyStateMessage("Loading Remote Agent...")
411+
} else {
412+
IdeaEmptyStateMessage("Loading Remote Agent...")
413+
}
359414
}
360415
AgentType.CODE_REVIEW -> {
361416
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {

0 commit comments

Comments
 (0)