diff --git a/mpp-idea/build.gradle.kts b/mpp-idea/build.gradle.kts index 2a8c3a18eb..b6febc62e5 100644 --- a/mpp-idea/build.gradle.kts +++ b/mpp-idea/build.gradle.kts @@ -396,6 +396,23 @@ project(":") { // Note: We use Dispatchers.EDT from IntelliJ Platform instead of Dispatchers.Swing compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + // ===== ACP (Agent Client Protocol) ===== + // Local ACP agent integration uses JSON-RPC over stdio. + // Exclude kotlinx deps to avoid conflicts with IntelliJ's bundled versions. + implementation("com.agentclientprotocol:acp:0.10.5") { + 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-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") + // We provide kotlinx-io explicitly below. + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-io-core") + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-io-core-jvm") + } + // ACP stdio transport uses kotlinx-io (not bundled by IntelliJ). + implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.8.0") + // mpp-core dependency for root project - use published artifact implementation("cc.unitmesh:mpp-core:${prop("mppVersion")}") { // Exclude Compose dependencies from mpp-core as well diff --git a/mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt b/mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt index 32d5ccd310..53751d202c 100644 --- a/mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt +++ b/mpp-idea/mpp-idea-core/src/main/kotlin/cc/unitmesh/devti/settings/AutoDevSettingsState.kt @@ -15,6 +15,24 @@ import com.intellij.util.xmlb.XmlSerializerUtil class AutoDevSettingsState : PersistentStateComponent { var delaySeconds = "" + // ===== ACP (Agent Client Protocol) integration ===== + /** + * ACP agent command to run (local process). + * Example: `node`, `python`, `autodev-agent`, etc. + */ + var acpCommand = "" + + /** + * ACP agent args as a single string (will be parsed into argv). + * Example: `path/to/agent.js --stdio`. + */ + var acpArgs = "" + + /** + * ACP agent environment variables in "KEY=VALUE" lines. + */ + var acpEnv = "" + // Legacy fields - kept for backward compatibility but deprecated @Deprecated("Use defaultModelId instead") var customOpenAiHost = "" 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 bdbc6ca522..83ccb97f13 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,10 +15,14 @@ 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.acp.IdeaAcpAgentContent +import cc.unitmesh.devins.idea.toolwindow.acp.IdeaAcpAgentViewModel 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.IdeaRemoteModeSelector +import cc.unitmesh.devins.idea.toolwindow.remote.RemoteAgentMode import cc.unitmesh.devins.idea.toolwindow.remote.getEffectiveProjectId import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditContent import cc.unitmesh.devins.idea.toolwindow.webedit.IdeaWebEditViewModel @@ -130,6 +134,9 @@ fun IdeaAgentApp( // Remote Agent ViewModel (created lazily when needed) var remoteAgentViewModel by remember { mutableStateOf(null) } + // ACP Agent ViewModel (created lazily when needed) + var acpAgentViewModel by remember { mutableStateOf(null) } + // WebEdit ViewModel (created lazily when needed) var webEditViewModel by remember { mutableStateOf(null) } @@ -164,30 +171,28 @@ fun IdeaAgentApp( serverUrl = "http://localhost:8080" ) } + if (currentAgentType == AgentType.REMOTE && acpAgentViewModel == null) { + acpAgentViewModel = IdeaAcpAgentViewModel(project, coroutineScope) + } if (currentAgentType == AgentType.WEB_EDIT && webEditViewModel == null) { webEditViewModel = IdeaWebEditViewModel(project, coroutineScope) } } - // Dispose ViewModels when leaving their tabs - DisposableEffect(currentAgentType) { + // Dispose ViewModels when the tool window is disposed. + // Keeping them alive across tab switches avoids reconnect churn and prevents leaks. + DisposableEffect(Unit) { onDispose { - if (currentAgentType != AgentType.CODE_REVIEW) { - codeReviewViewModel?.dispose() - codeReviewViewModel = null - } - if (currentAgentType != AgentType.KNOWLEDGE) { - knowledgeViewModel?.dispose() - knowledgeViewModel = null - } - if (currentAgentType != AgentType.REMOTE) { - remoteAgentViewModel?.dispose() - remoteAgentViewModel = null - } - if (currentAgentType != AgentType.WEB_EDIT) { - webEditViewModel?.dispose() - webEditViewModel = null - } + codeReviewViewModel?.dispose() + knowledgeViewModel?.dispose() + remoteAgentViewModel?.dispose() + acpAgentViewModel?.dispose() + webEditViewModel?.dispose() + codeReviewViewModel = null + knowledgeViewModel = null + remoteAgentViewModel = null + acpAgentViewModel = null + webEditViewModel = null } } @@ -306,13 +311,22 @@ fun IdeaAgentApp( ) } AgentType.REMOTE -> { - remoteAgentViewModel?.let { remoteVm -> - // Use manual state collection for remote agent states + val remoteVm = remoteAgentViewModel + val acpVm = acpAgentViewModel + if (remoteVm != null && acpVm != null) { + var remoteMode by remember { mutableStateOf(RemoteAgentMode.SERVER) } var remoteIsExecuting by remember { mutableStateOf(false) } + var acpIsExecuting by remember { mutableStateOf(false) } IdeaLaunchedEffect(remoteVm, project = project) { remoteVm.isExecuting.collect { remoteIsExecuting = it } } + IdeaLaunchedEffect(acpVm, project = project) { + acpVm.isExecuting.collect { acpIsExecuting = it } + } + + val isRemoteProcessing = remoteMode == RemoteAgentMode.SERVER && remoteIsExecuting + val isAcpProcessing = remoteMode == RemoteAgentMode.ACP && acpIsExecuting IdeaVerticalResizableSplitPane( modifier = Modifier.fillMaxWidth().weight(1f), @@ -320,27 +334,66 @@ fun IdeaAgentApp( minRatio = 0.3f, maxRatio = 0.9f, top = { - IdeaRemoteAgentContent( - viewModel = remoteVm, - listState = listState, - onProjectIdChange = { remoteProjectId = it }, - onGitUrlChange = { remoteGitUrl = it } - ) + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Start + ) { + IdeaRemoteModeSelector( + mode = remoteMode, + onModeChange = { remoteMode = it } + ) + } + Divider(Orientation.Horizontal, modifier = Modifier.fillMaxWidth().height(1.dp)) + + when (remoteMode) { + RemoteAgentMode.SERVER -> { + IdeaRemoteAgentContent( + viewModel = remoteVm, + listState = listState, + onProjectIdChange = { remoteProjectId = it }, + onGitUrlChange = { remoteGitUrl = it }, + modifier = Modifier.fillMaxSize() + ) + } + RemoteAgentMode.ACP -> { + IdeaAcpAgentContent( + viewModel = acpVm, + listState = listState, + modifier = Modifier.fillMaxSize() + ) + } + } + } }, bottom = { IdeaDevInInputArea( project = project, parentDisposable = viewModel, - isProcessing = remoteIsExecuting, + isProcessing = isRemoteProcessing || isAcpProcessing, 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") + when (remoteMode) { + RemoteAgentMode.SERVER -> { + val effectiveProjectId = getEffectiveProjectId(remoteProjectId, remoteGitUrl) + if (effectiveProjectId.isNotBlank()) { + remoteVm.executeTask(effectiveProjectId, task, remoteGitUrl) + } else { + remoteVm.renderer.renderError("Please provide a project or Git URL") + } + } + RemoteAgentMode.ACP -> { + acpVm.sendMessage(task) + } + } + }, + onAbort = { + when (remoteMode) { + RemoteAgentMode.SERVER -> remoteVm.cancelTask() + RemoteAgentMode.ACP -> acpVm.cancelTask() } }, - onAbort = { remoteVm.cancelTask() }, workspacePath = project.basePath, totalTokens = null, onAtClick = {}, @@ -355,7 +408,9 @@ fun IdeaAgentApp( ) } ) - } ?: IdeaEmptyStateMessage("Loading Remote Agent...") + } else { + IdeaEmptyStateMessage("Loading Remote Agent...") + } } AgentType.CODE_REVIEW -> { Box(modifier = Modifier.fillMaxWidth().weight(1f)) { diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentContent.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentContent.kt new file mode 100644 index 0000000000..236005f817 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentContent.kt @@ -0,0 +1,277 @@ +package cc.unitmesh.devins.idea.toolwindow.acp + +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.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import cc.unitmesh.devins.idea.compose.IdeaLaunchedEffect +import cc.unitmesh.devins.idea.components.timeline.IdeaTimelineContent +import cc.unitmesh.devins.idea.theme.IdeaAutoDevColors +import kotlinx.coroutines.flow.distinctUntilChanged +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.* + +/** + * ACP (Agent Client Protocol) content UI for IntelliJ IDEA plugin. + * + * It reuses the existing timeline renderer, but drives it from ACP session/update streams. + */ +@Composable +fun IdeaAcpAgentContent( + viewModel: IdeaAcpAgentViewModel, + listState: LazyListState, + modifier: Modifier = Modifier, +) { + var timeline by remember { mutableStateOf>(emptyList()) } + var streamingOutput by remember { mutableStateOf("") } + var isConnected by remember { mutableStateOf(false) } + var connectionError by remember { mutableStateOf(null) } + var stderrTail by remember { mutableStateOf>(emptyList()) } + + IdeaLaunchedEffect(viewModel.renderer) { + viewModel.renderer.timeline.collect { timeline = it } + } + IdeaLaunchedEffect(viewModel.renderer) { + viewModel.renderer.currentStreamingOutput.collect { streamingOutput = it } + } + IdeaLaunchedEffect(viewModel) { + viewModel.isConnected.collect { isConnected = it } + } + IdeaLaunchedEffect(viewModel) { + viewModel.connectionError.collect { connectionError = it } + } + IdeaLaunchedEffect(viewModel) { + viewModel.stderrTail.collect { stderrTail = it } + } + + // Load initial config from settings + val initialConfig = remember { viewModel.loadConfigFromSettings() } + var command by remember { mutableStateOf(initialConfig.command) } + var args by remember { mutableStateOf(initialConfig.args) } + var envText by remember { mutableStateOf(initialConfig.envText) } + var cwd by remember { mutableStateOf(initialConfig.cwd) } + + Column(modifier = modifier.fillMaxSize()) { + AcpConfigPanel( + command = command, + onCommandChange = { command = it }, + args = args, + onArgsChange = { args = it }, + cwd = cwd, + onCwdChange = { cwd = it }, + envText = envText, + onEnvChange = { envText = it }, + isConnected = isConnected, + connectionError = connectionError, + onStart = { + val cfg = AcpAgentConfig(command = command, args = args, envText = envText, cwd = cwd) + viewModel.saveConfigToSettings(cfg) + viewModel.connect(cfg) + }, + onStop = { viewModel.disconnect() }, + modifier = Modifier.fillMaxWidth() + ) + + if (stderrTail.isNotEmpty() && !isConnected) { + val tailText = remember(stderrTail) { stderrTail.takeLast(30).joinToString("\n") } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clip(RoundedCornerShape(6.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(8.dp) + ) { + Text( + text = tailText, + style = JewelTheme.defaultTextStyle.copy(color = JewelTheme.globalColors.text.info) + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + IdeaTimelineContent( + timeline = timeline, + streamingOutput = streamingOutput, + listState = listState, + project = viewModel.project + ) + } + } +} + +@Composable +private fun AcpConfigPanel( + command: String, + onCommandChange: (String) -> Unit, + args: String, + onArgsChange: (String) -> Unit, + cwd: String, + onCwdChange: (String) -> Unit, + envText: String, + onEnvChange: (String) -> Unit, + isConnected: Boolean, + connectionError: String?, + onStart: () -> Unit, + onStop: () -> Unit, + modifier: Modifier = Modifier, +) { + val commandState = rememberTextFieldState(command) + val argsState = rememberTextFieldState(args) + val cwdState = rememberTextFieldState(cwd) + var envValue by remember { mutableStateOf(TextFieldValue(envText)) } + + // Keep envValue in sync + IdeaLaunchedEffect(envText) { + if (envValue.text != envText) envValue = TextFieldValue(envText) + } + + // Sync textfield changes -> callbacks + IdeaLaunchedEffect(Unit) { + snapshotFlow { commandState.text.toString() } + .distinctUntilChanged() + .collect { onCommandChange(it) } + } + IdeaLaunchedEffect(Unit) { + snapshotFlow { argsState.text.toString() } + .distinctUntilChanged() + .collect { onArgsChange(it) } + } + IdeaLaunchedEffect(Unit) { + snapshotFlow { cwdState.text.toString() } + .distinctUntilChanged() + .collect { onCwdChange(it) } + } + IdeaLaunchedEffect(envValue.text) { + onEnvChange(envValue.text) + } + + // Sync external changes -> textfield states + IdeaLaunchedEffect(command) { + if (commandState.text.toString() != command) commandState.setTextAndPlaceCursorAtEnd(command) + } + IdeaLaunchedEffect(args) { + if (argsState.text.toString() != args) argsState.setTextAndPlaceCursorAtEnd(args) + } + IdeaLaunchedEffect(cwd) { + if (cwdState.text.toString() != cwd) cwdState.setTextAndPlaceCursorAtEnd(cwd) + } + + Column( + modifier = modifier + .background(JewelTheme.globalColors.panelBackground) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("ACP:", style = JewelTheme.defaultTextStyle, modifier = Modifier.width(60.dp)) + ConnectionStatusBar( + isConnected = isConnected, + connectionError = connectionError, + modifier = Modifier.weight(1f) + ) + + if (isConnected) { + OutlinedButton(onClick = onStop, modifier = Modifier.height(32.dp)) { Text("Stop") } + } else { + DefaultButton(onClick = onStart, modifier = Modifier.height(32.dp)) { Text("Start") } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Command:", style = JewelTheme.defaultTextStyle, modifier = Modifier.width(60.dp)) + TextField(state = commandState, placeholder = { Text("agent command (e.g. node)") }, modifier = Modifier.weight(1f)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Args:", style = JewelTheme.defaultTextStyle, modifier = Modifier.width(60.dp)) + TextField(state = argsState, placeholder = { Text("args (e.g. path/to/agent.js --stdio)") }, modifier = Modifier.weight(1f)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Cwd:", style = JewelTheme.defaultTextStyle, modifier = Modifier.width(60.dp)) + TextField(state = cwdState, placeholder = { Text("working directory") }, modifier = Modifier.weight(1f)) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text("Env (KEY=VALUE, one per line):", style = JewelTheme.defaultTextStyle) + TextArea( + value = envValue, + onValueChange = { envValue = it }, + modifier = Modifier.fillMaxWidth().height(80.dp), + placeholder = { Text("OPENAI_API_KEY=...\nHTTP_PROXY=...") } + ) + } + } +} + +@Composable +private fun ConnectionStatusBar( + isConnected: Boolean, + connectionError: String?, + modifier: Modifier = Modifier, +) { + val statusColor by animateColorAsState( + targetValue = if (isConnected) IdeaAutoDevColors.Green.c400 else IdeaAutoDevColors.Red.c400, + label = "acpStatusColor" + ) + + 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 + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(color = statusColor, shape = CircleShape) + ) + Text( + text = when { + isConnected -> "Connected" + connectionError != null -> "Error: $connectionError" + else -> "Not connected" + }, + style = JewelTheme.defaultTextStyle.copy( + color = JewelTheme.globalColors.text.normal.copy(alpha = 0.7f) + ) + ) + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt new file mode 100644 index 0000000000..0b79c59b38 --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/acp/IdeaAcpAgentViewModel.kt @@ -0,0 +1,612 @@ +package cc.unitmesh.devins.idea.toolwindow.acp + +import cc.unitmesh.agent.plan.MarkdownPlanParser +import cc.unitmesh.devins.idea.renderer.JewelRenderer +import cc.unitmesh.devti.settings.AutoDevSettingsState +import com.agentclientprotocol.client.Client +import com.agentclientprotocol.client.ClientInfo +import com.agentclientprotocol.client.ClientOperationsFactory +import com.agentclientprotocol.client.ClientSession +import com.agentclientprotocol.common.ClientSessionOperations +import com.agentclientprotocol.common.SessionCreationParameters +import com.agentclientprotocol.common.Event +import com.agentclientprotocol.model.* +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.transport.StdioTransport +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.util.concurrent.atomic.AtomicBoolean + +private val acpLogger = Logger.getInstance("AutoDevAcpAgent") + +/** + * ACP (Agent Client Protocol) ViewModel for IntelliJ IDEA plugin. + * + * MVP scope: + * - Spawn ACP agent as a local process (JSON-RPC over stdio) + * - initialize -> session/new -> session/prompt + * - Render session/update streaming events into existing timeline UI + * - Keep client capabilities minimal (fs/terminal disabled) + */ +class IdeaAcpAgentViewModel( + val project: Project, + private val coroutineScope: CoroutineScope, +) : Disposable { + 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 _stderrTail = MutableStateFlow>(emptyList()) + val stderrTail: StateFlow> = _stderrTail.asStateFlow() + + private var process: Process? = null + private var protocol: Protocol? = null + private var client: Client? = null + private var session: ClientSession? = null + + private var stderrJob: Job? = null + private var connectJob: Job? = null + private var currentPromptJob: Job? = null + + private val receivedAnyAgentChunk = AtomicBoolean(false) + private val inThoughtStream = AtomicBoolean(false) + + fun loadConfigFromSettings(): AcpAgentConfig { + val settings = AutoDevSettingsState.getInstance() + val cwd = project.basePath ?: System.getProperty("user.home") + return AcpAgentConfig( + command = settings.acpCommand.trim(), + args = settings.acpArgs.trim(), + envText = settings.acpEnv, + cwd = cwd + ) + } + + fun saveConfigToSettings(config: AcpAgentConfig) { + val settings = AutoDevSettingsState.getInstance() + settings.acpCommand = config.command + settings.acpArgs = config.args + settings.acpEnv = config.envText + } + + fun connect(config: AcpAgentConfig) { + connectJob?.cancel() + connectJob = coroutineScope.launch(Dispatchers.IO) { + try { + disconnectInternal() + receivedAnyAgentChunk.set(false) + inThoughtStream.set(false) + _connectionError.value = null + _stderrTail.value = emptyList() + + val cmd = config.command.trim() + if (cmd.isBlank()) { + _connectionError.value = "ACP agent command is empty." + _isConnected.value = false + return@launch + } + + val args = splitArgs(config.args) + val env = parseEnvLines(config.envText) + val cwd = config.cwd.ifBlank { project.basePath ?: System.getProperty("user.home") } + + val pb = ProcessBuilder(listOf(cmd) + args) + pb.directory(File(cwd)) + // Important: do NOT redirect stdout/stderr, stdout is used by ACP stdio transport. + pb.redirectInput(ProcessBuilder.Redirect.PIPE) + pb.redirectOutput(ProcessBuilder.Redirect.PIPE) + pb.redirectError(ProcessBuilder.Redirect.PIPE) + pb.environment().putAll(env) + + acpLogger.info("Starting ACP agent process: ${pb.command().joinToString(" ")} (cwd=$cwd)") + val started = pb.start() + process = started + + // Tail stderr for debugging and user visibility. + stderrJob = coroutineScope.launch(Dispatchers.IO) { + readStderrTail(started) + } + + val input = started.inputStream.asSource().buffered() + val output = started.outputStream.asSink().buffered() + + val transport = StdioTransport( + coroutineScope, + Dispatchers.IO, + input, + output, + "autodev-acp" + ) + val protocol = Protocol(coroutineScope, transport) + protocol.start() + + val client = Client(protocol) + this@IdeaAcpAgentViewModel.protocol = protocol + this@IdeaAcpAgentViewModel.client = client + + val clientInfo = ClientInfo( + protocolVersion = 1, + capabilities = ClientCapabilities( + fs = null, + terminal = false, + _meta = JsonNull + ), + implementation = Implementation( + name = "autodev-xiuper", + version = "dev", + title = "AutoDev Xiuper (ACP Client)", + _meta = JsonNull + ), + _meta = JsonNull + ) + + client.initialize(clientInfo, JsonNull) + + val operationsFactory = object : ClientOperationsFactory { + override suspend fun createClientOperations( + sessionId: SessionId, + sessionResponse: AcpCreatedSessionResponse, + ): ClientSessionOperations { + return IdeaAcpClientSessionOps( + onSessionUpdate = { update -> + handleSessionUpdate(update) + }, + onPermissionRequest = { toolCallUpdate, options -> + handlePermissionRequest(toolCallUpdate, options) + } + ) + } + } + + val session = client.newSession( + SessionCreationParameters( + cwd = cwd, + mcpServers = emptyList(), + _meta = JsonNull + ), + operationsFactory + ) + this@IdeaAcpAgentViewModel.session = session + + _isConnected.value = true + _connectionError.value = null + } catch (e: CancellationException) { + // Ignore + } catch (e: Exception) { + _isConnected.value = false + _connectionError.value = "Failed to start ACP agent: ${e.message}" + acpLogger.warn("ACP connect failed", e) + disconnectInternal() + } + } + } + + fun disconnect() { + connectJob?.cancel() + connectJob = null + coroutineScope.launch(Dispatchers.IO) { + disconnectInternal() + } + } + + fun sendMessage(text: String) { + if (_isExecuting.value) return + if (!_isConnected.value || session == null) { + renderer.renderError("ACP agent is not connected. Please start it first.") + return + } + + renderer.clearError() + renderer.addUserMessage(text) + + _isExecuting.value = true + currentPromptJob?.cancel() + currentPromptJob = coroutineScope.launch(Dispatchers.IO) { + try { + receivedAnyAgentChunk.set(false) + inThoughtStream.set(false) + + val flow = session!!.prompt( + listOf(ContentBlock.Text(text, Annotations(), JsonNull)), + JsonNull + ) + + flow.collect { event -> + when (event) { + is Event.SessionUpdateEvent -> handleSessionUpdate(event.update) + is Event.PromptResponseEvent -> { + finishStreamingIfNeeded() + val success = event.response.stopReason != StopReason.REFUSAL && + event.response.stopReason != StopReason.CANCELLED + renderer.renderFinalResult( + success = success, + message = "ACP finished: ${event.response.stopReason}", + iterations = 0 + ) + } + } + } + } catch (e: CancellationException) { + // If user cancels, try to cancel the session turn too. + try { + session?.cancel() + } catch (_: Exception) { + } + renderer.forceStop() + renderer.renderError("ACP turn cancelled by user.") + } catch (e: Exception) { + finishStreamingIfNeeded() + renderer.renderError(e.message ?: "ACP execution error") + } finally { + _isExecuting.value = false + currentPromptJob = null + } + } + } + + fun cancelTask() { + currentPromptJob?.cancel(CancellationException("Cancelled by user")) + currentPromptJob = null + _isExecuting.value = false + coroutineScope.launch(Dispatchers.IO) { + try { + session?.cancel() + } catch (_: Exception) { + } + } + } + + private fun finishStreamingIfNeeded() { + if (renderer.isProcessing.value) { + renderer.renderLLMResponseEnd() + } + if (inThoughtStream.getAndSet(false)) { + renderer.renderThinkingChunk("", isStart = false, isEnd = true) + } + } + + private fun handlePermissionRequest( + toolCall: SessionUpdate.ToolCallUpdate, + options: List, + ): RequestPermissionResponse { + // MVP: do not grant permissions. This forces tools to stay disabled unless user enables later. + return RequestPermissionResponse(RequestPermissionOutcome.Cancelled, JsonNull) + } + + private fun handleSessionUpdate(update: SessionUpdate, source: String = "prompt") { + when (update) { + is SessionUpdate.AgentMessageChunk -> { + if (!receivedAnyAgentChunk.getAndSet(true)) { + renderer.renderLLMResponseStart() + } + val text = extractText(update.content) + renderer.renderLLMResponseChunk(text) + } + + is SessionUpdate.AgentThoughtChunk -> { + val thought = extractText(update.content) + val isStart = !inThoughtStream.getAndSet(true) + renderer.renderThinkingChunk(thought, isStart = isStart, isEnd = false) + } + + is SessionUpdate.PlanUpdate -> { + // Convert ACP plan entries into our markdown plan model. + val markdown = buildString { + update.entries.forEachIndexed { index, entry -> + val marker = when (entry.status) { + PlanEntryStatus.COMPLETED -> "[x] " + PlanEntryStatus.IN_PROGRESS -> "[*] " + PlanEntryStatus.PENDING -> "" + else -> "" + } + appendLine("${index + 1}. $marker${entry.content}") + } + }.trim() + + if (markdown.isNotBlank()) { + try { + val plan = MarkdownPlanParser.parseToPlan(markdown) + renderer.setPlan(plan) + } catch (e: Exception) { + acpLogger.warn("Failed to parse ACP plan update", e) + } + } + } + + is SessionUpdate.ToolCall -> { + // Render tool call as a tool bubble using a safe wrapper param. + val toolTitle = update.title?.takeIf { it.isNotBlank() } ?: "tool" + val inputText = update.rawInput?.toString() ?: "" + renderer.renderToolCallWithParams( + toolName = toolTitle, + params = mapOf( + "kind" to (update.kind?.name ?: "UNKNOWN"), + "status" to (update.status?.name ?: "UNKNOWN"), + "input" to inputText + ) + ) + + // If agent already produced output in the same update, render it as a result. + val out = update.rawOutput?.toString() + val status = update.status + if (out != null && out.isNotBlank() && status != null && status != ToolCallStatus.PENDING && status != ToolCallStatus.IN_PROGRESS) { + renderer.renderToolResult( + toolName = toolTitle, + success = status == ToolCallStatus.COMPLETED, + output = out, + fullOutput = out, + metadata = emptyMap() + ) + } + } + + is SessionUpdate.ToolCallUpdate -> { + // Treat updates similarly to tool calls (may include progressive output). + val toolTitle = update.title?.takeIf { it.isNotBlank() } ?: "tool" + val inputText = update.rawInput?.toString() ?: "" + val out = update.rawOutput?.toString() + + renderer.renderToolCallWithParams( + toolName = toolTitle, + params = mapOf( + "kind" to (update.kind?.name ?: "UNKNOWN"), + "status" to (update.status?.name ?: "UNKNOWN"), + "input" to inputText + ) + ) + + val status = update.status + if (out != null && out.isNotBlank() && status != null && status != ToolCallStatus.PENDING && status != ToolCallStatus.IN_PROGRESS) { + renderer.renderToolResult( + toolName = toolTitle, + success = status == ToolCallStatus.COMPLETED, + output = out, + fullOutput = out, + metadata = emptyMap() + ) + } + } + + is SessionUpdate.CurrentModeUpdate -> { + // Surface mode changes as a system-like message. + renderer.renderLLMResponseStart() + renderer.renderLLMResponseChunk("Mode switched to: ${update.currentModeId}") + renderer.renderLLMResponseEnd() + } + + else -> { + // Ignore other updates for MVP. + acpLogger.debug("Unhandled ACP session update ($source): $update") + } + } + } + + private fun extractText(block: ContentBlock): String { + return when (block) { + is ContentBlock.Text -> block.text + else -> block.toString() + } + } + + private suspend fun disconnectInternal() { + _isConnected.value = false + _isExecuting.value = false + currentPromptJob?.cancel() + currentPromptJob = null + + try { + protocol?.close() + } catch (_: Exception) { + } + protocol = null + client = null + session = null + + stderrJob?.cancel() + stderrJob = null + + val p = process + process = null + if (p != null) { + try { + p.destroy() + } catch (_: Exception) { + } + withContext(Dispatchers.IO) { + try { + p.waitFor() + } catch (_: Exception) { + } + } + } + } + + private fun readStderrTail(p: Process, maxLines: Int = 200) { + try { + BufferedReader(InputStreamReader(p.errorStream)).useLines { lines -> + lines.forEach { line -> + _stderrTail.value = (_stderrTail.value + line).takeLast(maxLines) + } + } + } catch (e: Exception) { + // Ignore + } + } + + override fun dispose() { + disconnect() + } +} + +data class AcpAgentConfig( + val command: String, + val args: String, + val envText: String, + val cwd: String, +) + +private fun parseEnvLines(text: String): Map { + val result = linkedMapOf() + text.lines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isEmpty() || trimmed.startsWith("#")) return@forEach + val idx = trimmed.indexOf('=') + if (idx <= 0) return@forEach + val key = trimmed.substring(0, idx).trim() + val value = trimmed.substring(idx + 1).trim() + if (key.isNotBlank()) { + result[key] = value + } + } + return result +} + +/** + * Minimal argv parser that supports quoting with double quotes. + * + * Example: `--foo "bar baz"` => ["--foo", "bar baz"] + */ +private fun splitArgs(text: String): List { + val s = text.trim() + if (s.isEmpty()) return emptyList() + + val out = mutableListOf() + val buf = StringBuilder() + var inQuotes = false + var escape = false + + fun flush() { + if (buf.isNotEmpty()) { + out.add(buf.toString()) + buf.setLength(0) + } + } + + for (ch in s) { + when { + escape -> { + buf.append(ch) + escape = false + } + ch == '\\' -> { + escape = true + } + ch == '"' -> { + inQuotes = !inQuotes + } + ch.isWhitespace() && !inQuotes -> { + flush() + } + else -> buf.append(ch) + } + } + flush() + return out +} + +/** + * Minimal client operations used by ACP runtime. + * + * - notify(): forward session updates into the UI, in case the agent emits updates outside prompt flow + * - requestPermissions(): MVP denies all permissions + * - fs/terminal operations: not supported (capabilities disabled) + */ +private class IdeaAcpClientSessionOps( + private val onSessionUpdate: (SessionUpdate) -> Unit, + private val onPermissionRequest: (SessionUpdate.ToolCallUpdate, List) -> RequestPermissionResponse, +) : ClientSessionOperations { + override suspend fun notify(notification: SessionUpdate, _meta: kotlinx.serialization.json.JsonElement?) { + onSessionUpdate(notification) + } + + override suspend fun requestPermissions( + toolCall: SessionUpdate.ToolCallUpdate, + permissions: List, + _meta: kotlinx.serialization.json.JsonElement?, + ): RequestPermissionResponse { + return onPermissionRequest(toolCall, permissions) + } + + override suspend fun fsReadTextFile( + path: String, + line: UInt?, + limit: UInt?, + _meta: kotlinx.serialization.json.JsonElement?, + ): ReadTextFileResponse { + throw UnsupportedOperationException("ACP fs.read_text_file is disabled in this client") + } + + override suspend fun fsWriteTextFile( + path: String, + content: String, + _meta: kotlinx.serialization.json.JsonElement?, + ): WriteTextFileResponse { + throw UnsupportedOperationException("ACP fs.write_text_file is disabled in this client") + } + + override suspend fun terminalCreate( + command: String, + args: List, + cwd: String?, + env: List, + outputByteLimit: ULong?, + _meta: kotlinx.serialization.json.JsonElement?, + ): CreateTerminalResponse { + throw UnsupportedOperationException("ACP terminal.create is disabled in this client") + } + + override suspend fun terminalOutput( + terminalId: String, + _meta: kotlinx.serialization.json.JsonElement?, + ): TerminalOutputResponse { + throw UnsupportedOperationException("ACP terminal.output is disabled in this client") + } + + override suspend fun terminalRelease( + terminalId: String, + _meta: kotlinx.serialization.json.JsonElement?, + ): ReleaseTerminalResponse { + throw UnsupportedOperationException("ACP terminal.release is disabled in this client") + } + + override suspend fun terminalWaitForExit( + terminalId: String, + _meta: kotlinx.serialization.json.JsonElement?, + ): WaitForTerminalExitResponse { + throw UnsupportedOperationException("ACP terminal.wait_for_exit is disabled in this client") + } + + override suspend fun terminalKill( + terminalId: String, + _meta: kotlinx.serialization.json.JsonElement?, + ): KillTerminalCommandResponse { + throw UnsupportedOperationException("ACP terminal.kill is disabled in this client") + } +} + diff --git a/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteModeSelector.kt b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteModeSelector.kt new file mode 100644 index 0000000000..35379f1a5c --- /dev/null +++ b/mpp-idea/src/main/kotlin/cc/unitmesh/devins/idea/toolwindow/remote/IdeaRemoteModeSelector.kt @@ -0,0 +1,71 @@ +package cc.unitmesh.devins.idea.toolwindow.remote + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Text + +enum class RemoteAgentMode { + SERVER, + ACP, +} + +@Composable +fun IdeaRemoteModeSelector( + mode: RemoteAgentMode, + onModeChange: (RemoteAgentMode) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(JewelTheme.globalColors.panelBackground.copy(alpha = 0.5f)) + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Pill( + label = "Server", + selected = mode == RemoteAgentMode.SERVER, + onClick = { onModeChange(RemoteAgentMode.SERVER) } + ) + Pill( + label = "ACP", + selected = mode == RemoteAgentMode.ACP, + onClick = { onModeChange(RemoteAgentMode.ACP) } + ) + } +} + +@Composable +private fun Pill(label: String, selected: Boolean, onClick: () -> Unit) { + val bg by animateColorAsState( + targetValue = if (selected) JewelTheme.globalColors.text.normal.copy(alpha = 0.12f) else Color.Transparent, + label = "remoteModeBg" + ) + val fg by animateColorAsState( + targetValue = if (selected) JewelTheme.globalColors.text.normal else JewelTheme.globalColors.text.normal.copy(alpha = 0.7f), + label = "remoteModeFg" + ) + + Text( + text = label, + style = JewelTheme.defaultTextStyle.copy(color = fg), + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(bg) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) +} +