diff --git a/src/main/java/ee/carlrobert/codegpt/Icons.java b/src/main/java/ee/carlrobert/codegpt/Icons.java index e3569f4e..8a80fe63 100644 --- a/src/main/java/ee/carlrobert/codegpt/Icons.java +++ b/src/main/java/ee/carlrobert/codegpt/Icons.java @@ -17,7 +17,6 @@ public final class Icons { IconLoader.getIcon("/icons/expandAll.svg", Icons.class); public static final Icon Anthropic = IconLoader.getIcon("/icons/anthropic.svg", Icons.class); public static final Icon DeepSeek = IconLoader.getIcon("/icons/deepseek.png", Icons.class); - public static final Icon Qwen = IconLoader.getIcon("/icons/qwen.png", Icons.class); public static final Icon Google = IconLoader.getIcon("/icons/google.svg", Icons.class); public static final Icon Llama = IconLoader.getIcon("/icons/llama.svg", Icons.class); public static final Icon OpenAI = IconLoader.getIcon("/icons/openai.svg", Icons.class); diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt index e185f2a5..b71ad813 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/AgentService.kt @@ -8,21 +8,23 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgentService import ee.carlrobert.codegpt.agent.history.AgentCheckpointHistoryService import ee.carlrobert.codegpt.agent.history.CheckpointRef import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.toolwindow.agent.AgentSession import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowContentManager import ee.carlrobert.codegpt.ui.textarea.header.tag.McpTagDetails import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlin.time.Clock import kotlinx.serialization.json.JsonNull import java.util.* import java.util.concurrent.ConcurrentHashMap import kotlin.io.path.Path +import kotlin.time.Clock import ai.koog.prompt.message.Message as PromptMessage internal fun interface AgentRuntimeFactory { @@ -112,6 +114,12 @@ class AgentService(private val project: Project) { val provider = service().getServiceForFeature(FeatureType.AGENT) val contentManager = project.service() + val session = contentManager.getSession(sessionId) ?: return + if (!session.externalAgentId.isNullOrBlank()) { + submitExternalMessage(session, message, events, provider) + return + } + val runtimeAgentId = contentManager.getSession(sessionId)?.runtimeAgentId val runtime = runCatching { ensureSessionRuntime(sessionId, provider, events) @@ -156,6 +164,17 @@ class AgentService(private val project: Project) { } fun cancelCurrentRun(sessionId: String) { + val session = project.service().getSession(sessionId) + if (session?.externalAgentId != null) { + runCatching { + runBlocking { + project.service() + .cancelSession(sessionId, session.externalAgentSessionId) + } + }.onFailure { ex -> + logger.warn("Failed cancelling external ACP session for session=$sessionId", ex) + } + } sessionJobs[sessionId]?.cancel() sessionJobs.remove(sessionId) } @@ -171,6 +190,9 @@ class AgentService(private val project: Project) { logger.warn("Failed closing managed agent service for session=$sessionId", ex) } } + project.service().closeSession(sessionId) + project.service() + .getSession(sessionId)?.externalAgentSessionId = null project.service().clear(sessionId) } @@ -190,6 +212,40 @@ class AgentService(private val project: Project) { return sessionAgents[sessionId] } + private fun submitExternalMessage( + session: AgentSession, + message: MessageWithContext, + events: AgentEvents, + provider: ServiceType + ) { + val externalAgentService = project.service() + sessionJobs[session.sessionId] = CoroutineScope(Dispatchers.IO).launch { + try { + externalAgentService.runPromptLoop( + session = session, + firstMessage = message, + events = events, + pollNextQueued = { + val queue = pendingMessages[session.sessionId] ?: return@runPromptLoop null + if (queue.isEmpty()) { + null + } else { + queue.removeFirst() + } + } + ) + } catch (_: CancellationException) { + return@launch + } catch (ex: Throwable) { + logger.error(ex) + events.onAgentException(provider, ex) + } finally { + events.onRunCheckpointUpdated(message.id, null) + sessionJobs.remove(session.sessionId) + } + } + } + private fun updateMcpContext(sessionId: String, message: MessageWithContext) { val selectedServerIds = message.tags .filterIsInstance() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentCatalog.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentCatalog.kt new file mode 100644 index 00000000..75eb8f06 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentCatalog.kt @@ -0,0 +1,264 @@ +package ee.carlrobert.codegpt.agent.external + +data class ExternalAcpAgentPreset( + val id: String, + val displayName: String, + val vendor: String, + val command: String, + val args: List, + val env: Map = emptyMap(), + val enabledByDefault: Boolean = false, + val description: String? = null, +) { + fun fullCommand(): String = buildString { + append(command) + if (args.isNotEmpty()) { + append(' ') + append(args.joinToString(" ")) + } + } +} + +object ExternalAcpAgents { + + private val presets = listOf( + ExternalAcpAgentPreset( + id = "codex", + displayName = "Codex", + vendor = "OpenAI", + command = "npx", + args = listOf("-y", "@zed-industries/codex-acp"), + enabledByDefault = true, + description = "OpenAI Codex via the Zed ACP adapter." + ), + ExternalAcpAgentPreset( + id = "opencode", + displayName = "OpenCode", + vendor = "OpenCode", + command = "opencode", + args = listOf("acp"), + enabledByDefault = true, + description = "OpenCode CLI running its ACP server." + ), + ExternalAcpAgentPreset( + id = "cursor", + displayName = "Cursor", + vendor = "Cursor", + command = "agent", + args = listOf("acp"), + description = "Cursor Agent in ACP mode." + ), + ExternalAcpAgentPreset( + id = "claude-code", + displayName = "Claude Code", + vendor = "Anthropic", + command = "npx", + args = listOf("-y", "@zed-industries/claude-code-acp"), + enabledByDefault = true, + description = "Anthropic Claude Code via the Zed ACP adapter." + ), + ExternalAcpAgentPreset( + id = "gemini-cli", + displayName = "Gemini CLI", + vendor = "Google", + command = "gemini", + args = listOf("--experimental-acp"), + description = "Google Gemini CLI in experimental ACP mode." + ), + ExternalAcpAgentPreset( + id = "goose", + displayName = "Goose", + vendor = "Block", + command = "goose", + args = listOf("acp"), + description = "Block Goose running as an ACP server." + ), + ExternalAcpAgentPreset( + id = "github-copilot", + displayName = "GitHub Copilot", + vendor = "GitHub", + command = "copilot", + args = listOf("--acp"), + description = "GitHub Copilot CLI ACP server." + ), + ExternalAcpAgentPreset( + id = "qwen-code", + displayName = "Qwen Code", + vendor = "Qwen", + command = "qwen", + args = listOf("--acp"), + description = "Qwen Code running its ACP server." + ), + ExternalAcpAgentPreset( + id = "auggie", + displayName = "Auggie CLI", + vendor = "Augment", + command = "auggie", + args = listOf("--acp"), + description = "Augment's Auggie CLI in ACP mode." + ), + ExternalAcpAgentPreset( + id = "agentpool", + displayName = "AgentPool", + vendor = "AgentPool", + command = "agentpool", + args = listOf("serve-acp", "agents.yml"), + description = "AgentPool serving ACP from a local agents.yml configuration." + ), + ExternalAcpAgentPreset( + id = "blackbox-ai", + displayName = "Blackbox AI", + vendor = "Blackbox AI", + command = "blackbox", + args = listOf("--experimental-acp"), + description = "Blackbox CLI running in experimental ACP mode." + ), + ExternalAcpAgentPreset( + id = "claude-agent", + displayName = "Claude Agent", + vendor = "Anthropic", + command = "npx", + args = listOf("-y", "@zed-industries/claude-agent-acp"), + description = "Anthropic Claude Agent via the Zed ACP adapter." + ), + ExternalAcpAgentPreset( + id = "cline", + displayName = "Cline", + vendor = "Cline", + command = "npx", + args = listOf("-y", "cline", "--acp"), + description = "Cline running as an ACP server." + ), + ExternalAcpAgentPreset( + id = "code-assistant", + displayName = "Code Assistant", + vendor = "stippi", + command = "code-assistant", + args = listOf("acp"), + description = "Code Assistant running in ACP agent mode." + ), + ExternalAcpAgentPreset( + id = "docker-cagent", + displayName = "Docker's cagent", + vendor = "Docker", + command = "cagent", + args = listOf("acp"), + description = "Docker cagent serving ACP; project agent configuration may still be required." + ), + ExternalAcpAgentPreset( + id = "fast-agent", + displayName = "fast-agent", + vendor = "fast-agent", + command = "uvx", + args = listOf("fast-agent-acp", "-x"), + description = "fast-agent's ACP bridge via uvx." + ), + ExternalAcpAgentPreset( + id = "factory-droid", + displayName = "Factory Droid", + vendor = "Factory AI", + command = "npx", + args = listOf("-y", "droid", "exec", "--output-format", "acp"), + env = mapOf( + "DROID_DISABLE_AUTO_UPDATE" to "true", + "FACTORY_DROID_AUTO_UPDATE_ENABLED" to "false", + ), + description = "Factory Droid running in ACP mode." + ), + ExternalAcpAgentPreset( + id = "junie", + displayName = "Junie", + vendor = "JetBrains", + command = "junie", + args = listOf("--acp=true"), + description = "JetBrains Junie running as an ACP agent." + ), + ExternalAcpAgentPreset( + id = "kimi-cli", + displayName = "Kimi CLI", + vendor = "Moonshot AI", + command = "kimi", + args = listOf("acp"), + description = "Moonshot AI's Kimi CLI in ACP mode." + ), + ExternalAcpAgentPreset( + id = "kiro-cli", + displayName = "Kiro CLI", + vendor = "Kiro", + command = "kiro", + args = listOf("--acp"), + description = "Kiro CLI running as an ACP-compliant agent." + ), + ExternalAcpAgentPreset( + id = "minion-code", + displayName = "Minion Code", + vendor = "Minion", + command = "uvx", + args = listOf("minion-code", "acp"), + description = "Minion Code running its ACP server." + ), + ExternalAcpAgentPreset( + id = "mistral-vibe", + displayName = "Mistral Vibe", + vendor = "Mistral AI", + command = "vibe-acp", + args = emptyList(), + description = "Mistral Vibe's ACP bridge." + ), + ExternalAcpAgentPreset( + id = "openclaw", + displayName = "OpenClaw", + vendor = "OpenClaw", + command = "openclaw", + args = listOf("acp"), + description = "OpenClaw running as an ACP server." + ), + ExternalAcpAgentPreset( + id = "openhands", + displayName = "OpenHands", + vendor = "All Hands AI", + command = "openhands", + args = listOf("acp"), + description = "OpenHands in ACP mode." + ), + ExternalAcpAgentPreset( + id = "pi", + displayName = "Pi", + vendor = "pi", + command = "npx", + args = listOf("-y", "pi-acp"), + description = "Pi via the pi ACP adapter." + ), + ExternalAcpAgentPreset( + id = "qoder-cli", + displayName = "Qoder CLI", + vendor = "Qoder AI", + command = "npx", + args = listOf("-y", "@qoder-ai/qodercli", "--acp"), + description = "Qoder CLI running its ACP server." + ), + ExternalAcpAgentPreset( + id = "stakpak", + displayName = "Stakpak", + vendor = "Stakpak", + command = "stakpak", + args = listOf("acp"), + description = "Stakpak running in ACP mode." + ), + ExternalAcpAgentPreset( + id = "vt-code", + displayName = "VT Code", + vendor = "VT Code", + command = "vtcode", + args = listOf("acp"), + description = "VT Code running its ACP bridge." + ) + ) + + fun all(): List = presets.sortedBy { it.displayName.lowercase() } + + fun find(id: String?): ExternalAcpAgentPreset? = presets.firstOrNull { it.id == id } + + fun enabledByDefaultIds(): List = + presets.filter { it.enabledByDefault }.map { it.id } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt new file mode 100644 index 00000000..8bcb9221 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt @@ -0,0 +1,739 @@ +package ee.carlrobert.codegpt.agent.external + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VfsUtil +import ee.carlrobert.codegpt.agent.AgentEvents +import ee.carlrobert.codegpt.agent.MessageWithContext +import ee.carlrobert.codegpt.agent.ToolSpecs +import ee.carlrobert.codegpt.agent.tools.BashTool +import ee.carlrobert.codegpt.agent.tools.EditTool +import ee.carlrobert.codegpt.agent.tools.WriteTool +import ee.carlrobert.codegpt.settings.mcp.McpSettings +import ee.carlrobert.codegpt.toolwindow.agent.AgentSession +import ee.carlrobert.codegpt.toolwindow.agent.AgentToolWindowContentManager +import ee.carlrobert.codegpt.toolwindow.agent.ui.approval.* +import ee.carlrobert.codegpt.ui.textarea.header.tag.* +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.createDirectories +import kotlin.io.path.notExists + +private data class ExternalToolCall( + val toolName: String, + val args: Any? +) + +@Service(Service.Level.PROJECT) +class ExternalAcpAgentService(private val project: Project) { + + private companion object { + const val PROTOCOL_VERSION = 1 + const val FULL_ACCESS_MODE_ID = "full-access" + + val NO_OP_EVENTS = object : AgentEvents { + override fun onQueuedMessagesResolved() = Unit + override fun onAgentException( + provider: ee.carlrobert.codegpt.settings.service.ServiceType, + throwable: Throwable + ) = Unit + } + } + + private val logger = thisLogger() + private val json = Json { ignoreUnknownKeys = true } + private val sessionConfigAdapter = AcpSessionConfigAdapter(json) + private val toolCallDecoder = AcpToolCallDecoder(json) + private val sessionUpdateParser = AcpSessionUpdateParser(toolCallDecoder) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val states = ConcurrentHashMap() + private val sessionSetupMutexes = ConcurrentHashMap() + + suspend fun runPromptLoop( + session: AgentSession, + firstMessage: MessageWithContext, + events: AgentEvents, + pollNextQueued: () -> MessageWithContext? + ) { + val preset = ExternalAcpAgents.find(session.externalAgentId) + ?: error("Unsupported external agent: ${session.externalAgentId}") + val state = ensureSessionReady(session, preset, events) + + var current: MessageWithContext? = firstMessage + while (current != null && scope.isActive) { + val promptMessage = current + val externalSessionId = session.externalAgentSessionId + ?: error("Missing ACP session id for ${session.sessionId}") + + try { + sendPrompt(state, externalSessionId, promptMessage) + } catch (cancelled: CancellationException) { + cancelSession(state, externalSessionId) + throw cancelled + } + + val nextMessage = pollNextQueued() + if (nextMessage == null) { + events.onAgentCompleted(preset.displayName) + return + } + + events.onQueuedMessagesResolved() + current = nextMessage + delay(50) + } + } + + fun closeSession(sessionId: String) { + states.remove(sessionId)?.close() + sessionSetupMutexes.remove(sessionId) + } + + suspend fun cancelSession(sessionId: String, externalSessionId: String?) { + val state = states[sessionId] ?: return + val activeSessionId = externalSessionId ?: return + cancelSession(state, activeSessionId) + } + + suspend fun warmUpSession(session: AgentSession) { + val preset = ExternalAcpAgents.find(session.externalAgentId) ?: return + ensureSessionReady(session, preset, NO_OP_EVENTS) + } + + suspend fun setSessionConfigOption( + session: AgentSession, + optionId: String, + value: String + ) { + val preset = ExternalAcpAgents.find(session.externalAgentId) + ?: error("Unsupported external agent: ${session.externalAgentId}") + val state = ensureSessionReady(session, preset, NO_OP_EVENTS) + val externalSessionId = session.externalAgentSessionId + ?: error("Missing ACP session id for ${session.sessionId}") + val result = sessionConfigAdapter.updateOption( + request = AcpConfigUpdateRequest( + sessionId = externalSessionId, + optionId = optionId, + value = value + ), + support = state.configUpdateSupport, + sendRequest = state::sendRequest + ) + when (result) { + AcpConfigUpdateResult.Unsupported -> { + state.configUpdateSupport = AcpConfigUpdateSupport.Unsupported + throw IllegalStateException("${preset.displayName} does not support runtime option changes") + } + + is AcpConfigUpdateResult.Applied -> { + state.configUpdateSupport = result.support + updateSessionConfigOptions(session, result.response) + } + } + } + + private suspend fun ensureSessionReady( + session: AgentSession, + preset: ExternalAcpAgentPreset, + events: AgentEvents + ): AcpProcessState { + val mutex = sessionSetupMutexes.computeIfAbsent(session.sessionId) { Mutex() } + return mutex.withLock { + val state = ensureState(session, preset, events) + if (session.externalAgentSessionId.isNullOrBlank()) { + session.externalAgentConfigLoading = true + session.externalAgentSessionId = createSession(state, session) + } + state + } + } + + private suspend fun ensureState( + session: AgentSession, + preset: ExternalAcpAgentPreset, + events: AgentEvents + ): AcpProcessState { + val existing = states[session.sessionId] + if (existing != null && existing.preset.id == preset.id && existing.isAlive()) { + existing.events = events + return existing + } + + existing?.close() + val resolvedCommand = AcpProcessHelper.resolveCommand( + command = preset.command, + extraEnvironment = preset.env + ) + ?: throw IllegalStateException( + AcpProcessHelper.getCommandNotFoundMessage(preset.command) + ) + val enhancedEnv = AcpProcessHelper.createEnvironment( + extraEnvironment = preset.env, + resolvedCommand = resolvedCommand + ) + + val process = withContext(Dispatchers.IO) { + GeneralCommandLine().apply { + withExePath(resolvedCommand) + withParameters(preset.args) + withWorkDirectory(File(project.basePath ?: System.getProperty("user.dir"))) + withEnvironment(enhancedEnv) + withParentEnvironmentType(GeneralCommandLine.ParentEnvironmentType.NONE) + withRedirectErrorStream(false) + }.createProcess() + } + + val state = AcpProcessState( + proxySessionId = session.sessionId, + preset = preset, + process = process, + events = events + ) + states[session.sessionId] = state + state.startReader() + state.startStderrLogger() + initialize(state) + return state + } + + private suspend fun initialize(state: AcpProcessState) { + val response = state.sendRequest( + method = "initialize", + params = buildJsonObject { + put("protocolVersion", PROTOCOL_VERSION) + putJsonObject("clientCapabilities") { + putJsonObject("fs") { + put("readTextFile", true) + put("writeTextFile", true) + } + } + putJsonObject("clientInfo") { + put("name", "proxyai") + put("title", "ProxyAI") + put("version", "dev") + } + } + ) + state.authMethodIds = + response["authMethods"]?.jsonArray?.mapNotNull { it.jsonObject.string("id") }.orEmpty() + } + + private suspend fun createSession(state: AcpProcessState, session: AgentSession): String { + return try { + val response = runCatching { + state.sendRequest( + method = "session/new", + params = buildJsonObject { + put("cwd", project.basePath ?: System.getProperty("user.dir")) + put("mcpServers", buildMcpServers(session.sessionId)) + } + ) + }.recoverCatching { ex -> + if (ex.isAuthenticationRequiredError() && state.authMethodIds.isNotEmpty()) { + authenticate(state, state.authMethodIds.first()) + state.sendRequest( + method = "session/new", + params = buildJsonObject { + put("cwd", project.basePath ?: System.getProperty("user.dir")) + put("mcpServers", buildMcpServers(session.sessionId)) + } + ) + } else { + throw ex + } + }.getOrThrow() + updateSessionConfigOptions(session, response) + response["sessionId"]?.jsonPrimitive?.content + ?: error("ACP agent did not return a sessionId") + } finally { + session.externalAgentConfigLoading = false + } + } + + private suspend fun authenticate(state: AcpProcessState, methodId: String) { + state.sendRequest( + method = "authenticate", + params = buildJsonObject { + put("methodId", methodId) + } + ) + } + + private fun updateSessionConfigOptions(session: AgentSession, response: JsonObject) { + session.externalAgentConfigOptions = sessionConfigAdapter.merge( + existing = session.externalAgentConfigOptions, + response = response + ) + session.externalAgentConfigLoading = false + } + + private suspend fun sendPrompt( + state: AcpProcessState, + externalSessionId: String, + message: MessageWithContext + ) { + state.sendRequest( + method = "session/prompt", + params = buildJsonObject { + put("sessionId", externalSessionId) + put("prompt", buildPromptBlocks(message)) + } + ) + } + + private suspend fun cancelSession(state: AcpProcessState, externalSessionId: String) { + runCatching { + state.sendNotification( + method = "session/cancel", + params = buildJsonObject { + put("sessionId", externalSessionId) + } + ) + }.onFailure { + logger.debug("Failed to cancel ACP session $externalSessionId", it) + } + } + + private fun buildMcpServers(proxySessionId: String): JsonArray { + val selectedServerIds = + project.service() + .get(proxySessionId) + ?.selectedServerIds + .orEmpty() + if (selectedServerIds.isEmpty()) { + return JsonArray(emptyList()) + } + + val serversById = + project.service().state.servers.associateBy { it.id.toString() } + return JsonArray(selectedServerIds.mapNotNull { serverId -> + val server = serversById[serverId] ?: return@mapNotNull null + buildJsonObject { + put("type", "stdio") + put("name", server.name ?: serverId) + put("command", server.command ?: "npx") + putJsonArray("args") { + server.arguments.forEach { add(JsonPrimitive(it)) } + } + putJsonArray("env") { + server.environmentVariables.forEach { (key, value) -> + add( + buildJsonObject { + put("name", key) + put("value", value) + } + ) + } + } + } + }) + } + + private fun buildPromptBlocks(message: MessageWithContext): JsonArray { + val blocks = mutableListOf() + val selectedTags = message.tags.filter { it.selected } + + if (selectedTags.isNotEmpty()) { + val tagSummary = buildTagSummary(selectedTags) + if (tagSummary.isNotBlank()) { + blocks += buildJsonObject { + put("type", "text") + put("text", tagSummary) + } + } + } + + blocks += buildJsonObject { + put("type", "text") + put("text", message.text) + } + + selectedTags.forEach { tag -> + when (tag) { + is FileTagDetails -> blocks += resourceLinkBlock(tag.virtualFile.path) + is EditorTagDetails -> blocks += resourceLinkBlock(tag.virtualFile.path) + else -> Unit + } + } + + return JsonArray(blocks) + } + + private fun resourceLinkBlock(path: String): JsonObject { + val filePath = Paths.get(path) + return buildJsonObject { + put("type", "resource_link") + put("uri", Paths.get(path).toUri().toString()) + put("name", filePath.fileName?.toString() ?: path) + put("mimeType", "text/plain") + } + } + + private fun buildTagSummary(tags: List): String { + if (tags.isEmpty()) return "" + + return buildString { + appendLine("Additional ProxyAI context:") + tags.forEach { tag -> + when (tag) { + is FileTagDetails -> appendLine("- File: ${tag.virtualFile.path}") + is EditorTagDetails -> appendLine("- Open file: ${tag.virtualFile.path}") + is FolderTagDetails -> appendLine("- Folder: ${tag.folder.path}") + is SelectionTagDetails -> { + appendLine("- Selection from ${tag.virtualFile.path}:") + appendLine(tag.selectedText.orEmpty()) + } + + is EditorSelectionTagDetails -> { + appendLine("- Selection from ${tag.virtualFile.path}:") + appendLine(tag.selectedText.orEmpty()) + } + + else -> appendLine("- ${tag.name}") + } + } + }.trim() + } + + private inner class AcpProcessState( + val proxySessionId: String, + val preset: ExternalAcpAgentPreset, + val process: Process, + @Volatile var events: AgentEvents + ) { + private val toolCallsById = ConcurrentHashMap() + private val rpcConnection = AcpJsonRpcConnection( + json = json, + process = process, + scope = scope, + logger = logger, + processName = preset.displayName, + onRequest = ::handleRequest, + onNotification = { notification -> handleNotification(notification, events) } + ) + + @Volatile + var authMethodIds: List = emptyList() + + @Volatile + var configUpdateSupport: AcpConfigUpdateSupport = AcpConfigUpdateSupport.Unknown + + fun isAlive(): Boolean = rpcConnection.isAlive() + + fun startReader() = rpcConnection.startReader() + + fun startStderrLogger() = rpcConnection.startStderrLogger() + + suspend fun sendRequest(method: String, params: JsonObject): JsonObject = + rpcConnection.request(method, params) + + suspend fun sendNotification(method: String, params: JsonObject) = + rpcConnection.notify(method, params) + + private suspend fun handleRequest(request: AcpJsonRpcRequest): JsonElement? { + return when (request.method) { + "session/request_permission", "requestPermission" -> + handleRequestPermission(request.params) + + "fs/read_text_file", "readTextFile" -> handleReadTextFile(request.params) + "fs/write_text_file", "writeTextFile" -> handleWriteTextFile(request.params) + else -> null + } + } + + private fun handleNotification(notification: AcpJsonRpcNotification, events: AgentEvents) { + when (val update = sessionUpdateParser.parse(notification)) { + null -> Unit + is AcpSessionUpdate.TextChunk -> events.onTextReceived(update.text) + is AcpSessionUpdate.ThoughtChunk -> events.onTextReceived("${update.text}") + is AcpSessionUpdate.ToolCall -> handleToolCall(update, events) + is AcpSessionUpdate.ToolCallUpdate -> handleToolCallUpdate(update, events) + is AcpSessionUpdate.ConfigOptionUpdate -> handleConfigOptionUpdate(update.update) + } + } + + private suspend fun handleRequestPermission(params: JsonObject): JsonObject { + val requestData = toolCallDecoder.decodePermissionRequest(params) + val session = currentSession() + val mode = session.currentAcpMode() + if (mode == FULL_ACCESS_MODE_ID) { + logger.debug( + "Auto-approving ${preset.displayName} ACP permission in mode=$mode for tool=${requestData.toolName} title=${requestData.rawTitle}" + ) + return permissionResponse(selectApprovedPermissionOptionId(requestData.options)) + } + + val request = buildApprovalRequest( + requestData.rawTitle, + requestData.details, + requestData.toolName, + requestData.parsedArgs + ) + val approved = runCatching { events.approveToolCall(request) }.getOrDefault(false) + val selectedOptionId = if (approved) { + selectApprovedPermissionOptionId(requestData.options) + } else { + selectRejectedPermissionOptionId(requestData.options) + } + logger.debug( + "Resolved ${preset.displayName} ACP permission in mode=$mode for tool=${requestData.toolName} approved=$approved title=${requestData.rawTitle}" + ) + return permissionResponse(selectedOptionId) + } + + private fun handleToolCall(update: AcpSessionUpdate.ToolCall, events: AgentEvents) { + val toolCall = update.toolCall + toolCallsById[toolCall.id] = ExternalToolCall(toolCall.toolName, toolCall.args) + events.onToolStarting(toolCall.id, toolCall.toolName, toolCall.args) + + if (update.status?.isTerminal == true) { + completeToolCall( + toolCallId = toolCall.id, + toolName = toolCall.toolName, + args = toolCall.args, + status = update.status, + rawOutput = update.rawOutput, + events = events + ) + } + } + + private fun handleToolCallUpdate( + update: AcpSessionUpdate.ToolCallUpdate, + events: AgentEvents + ) { + if (!update.status.isTerminal) { + return + } + + val toolCall = toolCallsById[update.toolCallId] + completeToolCall( + toolCallId = update.toolCallId, + toolName = toolCall?.toolName ?: "Tool", + args = toolCall?.args, + status = update.status, + rawOutput = update.rawOutput, + events = events + ) + } + + private fun completeToolCall( + toolCallId: String, + toolName: String, + args: Any?, + status: AcpToolCallStatus, + rawOutput: JsonElement?, + events: AgentEvents + ) { + val result = toolCallDecoder.decodeResult( + toolName = toolName, + args = args, + status = status, + rawOutput = rawOutput + ) + toolCallsById.remove(toolCallId) + events.onToolCompleted(toolCallId, toolName, result) + } + + private fun handleConfigOptionUpdate(update: JsonObject) { + project.service() + .getSession(proxySessionId) + ?.let { session -> + updateSessionConfigOptions(session, update) + } + } + + private fun handleReadTextFile(params: JsonObject): JsonObject { + val path = requestPath(params) + val raw = Files.readString(path) + val line = params["line"]?.jsonPrimitive?.intOrNull + val limit = params["limit"]?.jsonPrimitive?.intOrNull + val content = if (line != null && limit != null && line > 0 && limit > 0) { + raw.lineSequence() + .drop(line - 1) + .take(limit) + .joinToString("\n") + } else { + raw + } + return buildJsonObject { + put("content", content) + } + } + + private fun handleWriteTextFile(params: JsonObject): JsonElement { + val path = requestPath(params) + val content = params["content"]?.jsonPrimitive?.content ?: "" + if (path.parent != null && path.parent.notExists()) { + path.parent.createDirectories() + } + Files.writeString(path, content) + val virtualFile = + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(path.toFile()) + if (virtualFile != null) { + VfsUtil.markDirtyAndRefresh(false, false, false, virtualFile) + } else { + val parent = path.parent?.toFile() + if (parent != null) { + LocalFileSystem.getInstance().refreshAndFindFileByIoFile(parent) + ?.let { parentVf -> + VfsUtil.markDirtyAndRefresh(false, false, true, parentVf) + } + } + } + return JsonNull + } + + private fun selectApprovedPermissionOptionId(options: JsonArray): String { + return selectPermissionOptionId( + options = options, + preferredKinds = listOf("allow_once", "allow_always", "trust"), + fallback = "allow" + ) + } + + private fun selectRejectedPermissionOptionId(options: JsonArray): String { + return selectPermissionOptionId( + options = options, + preferredKinds = listOf("reject_once", "reject_always", "deny", "abort", "cancel"), + fallback = "abort", + predicate = { value -> + value.contains("reject") || value.contains("deny") || + value.contains("abort") || value.contains("cancel") + } + ) + } + + private fun selectPermissionOptionId( + options: JsonArray, + preferredKinds: List, + fallback: String, + predicate: (String) -> Boolean = { false } + ): String { + preferredKinds.forEach { preferred -> + options.firstOrNull { optionMatches(it.jsonObject, preferred) }?.let { + return optionIdOf(it.jsonObject) ?: preferred + } + } + + options.firstOrNull { option -> + optionValues(option.jsonObject).any(predicate) + }?.let { option -> + return optionIdOf(option.jsonObject) ?: fallback + } + + return options.firstOrNull()?.jsonObject?.let(::optionIdOf) ?: fallback + } + + private fun optionMatches(option: JsonObject, expected: String): Boolean { + return optionValues(option).any { it == expected } + } + + private fun optionValues(option: JsonObject): List { + return listOfNotNull( + option["kind"]?.jsonPrimitive?.contentOrNull?.lowercase(), + option["optionId"]?.jsonPrimitive?.contentOrNull?.lowercase(), + option["id"]?.jsonPrimitive?.contentOrNull?.lowercase() + ) + } + + private fun optionIdOf(option: JsonObject): String? { + return option["optionId"]?.jsonPrimitive?.contentOrNull + ?: option["id"]?.jsonPrimitive?.contentOrNull + } + + private fun buildApprovalRequest( + rawTitle: String, + details: String, + toolName: String, + parsedArgs: Any? + ): ToolApprovalRequest { + val approvalType = when (parsedArgs) { + is WriteTool.Args -> ToolApprovalType.WRITE + is EditTool.Args -> ToolApprovalType.EDIT + is BashTool.Args -> ToolApprovalType.BASH + else -> ToolSpecs.approvalTypeFor(toolName) + } + val payload = approvalPayload(parsedArgs) + val title = rawTitle.ifBlank { + when (approvalType) { + ToolApprovalType.BASH -> "Run shell command?" + ToolApprovalType.WRITE -> "Write file?" + ToolApprovalType.EDIT -> "Edit file?" + ToolApprovalType.GENERIC -> "Allow action?" + } + } + return ToolApprovalRequest( + type = approvalType, + title = title, + details = details, + payload = payload + ) + } + + private fun approvalPayload(parsedArgs: Any?): ToolApprovalPayload? { + return when (parsedArgs) { + is WriteTool.Args -> WritePayload(parsedArgs.filePath, parsedArgs.content) + is EditTool.Args -> EditPayload( + filePath = parsedArgs.filePath, + oldString = parsedArgs.oldString, + newString = parsedArgs.newString, + replaceAll = parsedArgs.replaceAll + ) + + is BashTool.Args -> BashPayload(parsedArgs.command, parsedArgs.description) + else -> null + } + } + + private fun permissionResponse(optionId: String): JsonObject { + return buildJsonObject { + putJsonObject("outcome") { + put("outcome", "selected") + put("optionId", optionId) + } + } + } + + private fun currentSession(): AgentSession? { + return project.service().getSession(proxySessionId) + } + + fun close() = rpcConnection.close() + } + + private fun AgentSession?.currentAcpMode(): String? { + return this?.externalAgentConfigOptions + ?.firstOrNull(AcpSessionConfigId.MODE::matches) + ?.currentValue + } + + private fun requestPath(params: JsonObject): Path { + val rawPath = params["path"]?.jsonPrimitive?.contentOrNull + ?: params["uri"]?.jsonPrimitive?.contentOrNull + ?: error("Missing path") + return uriToPath(rawPath) + } + + private fun uriToPath(uri: String): Path { + return when { + uri.startsWith("file://") -> Paths.get(java.net.URI.create(uri)) + else -> Paths.get(uri) + } + } + + private fun Throwable.isAuthenticationRequiredError(): Boolean { + return message?.contains("Authentication required", ignoreCase = true) == true + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpIcons.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpIcons.kt new file mode 100644 index 00000000..a086ab25 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpIcons.kt @@ -0,0 +1,47 @@ +package ee.carlrobert.codegpt.agent.external + +import com.intellij.openapi.util.IconLoader +import com.intellij.util.IconUtil +import ee.carlrobert.codegpt.Icons +import javax.swing.Icon + +object AcpIcons { + + private const val ACP_ICON_ROOT = "/icons/agents" + private const val TARGET_ICON_SIZE = 16 + + private val iconFileOverrides = mapOf( + "agentpool" to "agentpool.png", + "auggie" to "auggie.png", + "blackbox-ai" to "blackbox.svg", + "claude-code" to "claude.svg", + "gemini-cli" to "gemini-agent.svg", + "kimi-cli" to "kimi.svg", + "kiro-cli" to "kiro.svg", + "pi" to "pi-acp.svg", + "qoder-cli" to "qoder.svg", + "qwen-code" to "qwen.png", + "vt-code" to "vtcode.svg" + ) + + fun iconFor(agentId: String?): Icon { + val resolvedAgentId = agentId ?: return Icons.DefaultSmall + return acpIconOrNull(iconFileOverrides[resolvedAgentId] ?: "$resolvedAgentId.svg") + ?: Icons.DefaultSmall + } + + private fun acpIconOrNull(fileName: String): Icon? { + val icon = + IconLoader.findIcon("$ACP_ICON_ROOT/$fileName", AcpIcons::class.java) + ?: return null + return normalize(icon) + } + + private fun normalize(icon: Icon): Icon { + val maxDimension = maxOf(icon.iconWidth, icon.iconHeight) + if (maxDimension <= TARGET_ICON_SIZE) { + return icon + } + return IconUtil.scale(icon, null, TARGET_ICON_SIZE.toFloat() / maxDimension.toFloat()) + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJson.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJson.kt new file mode 100644 index 00000000..93a0309c --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJson.kt @@ -0,0 +1,88 @@ +package ee.carlrobert.codegpt.agent.external + +import kotlinx.serialization.json.* + +internal fun JsonObject.string(vararg keys: String): String? { + return keys.firstNotNullOfOrNull { key -> + when (val element = this[key]) { + is JsonPrimitive -> element.contentOrNull?.takeIf { it.isNotBlank() } + else -> null + } + } +} + +internal fun JsonObject.commandString(): String? { + return listOf("command", "cmd").firstNotNullOfOrNull { key -> + when (val element = this[key]) { + is JsonPrimitive -> element.contentOrNull?.takeIf { it.isNotBlank() } + is JsonArray -> element.mapNotNull { item -> + (item as? JsonPrimitive)?.contentOrNull + }.takeIf { it.isNotEmpty() }?.joinToString(" ") + + else -> null + } + } +} + +internal fun JsonObject.boolean(vararg keys: String): Boolean? { + return keys.firstNotNullOfOrNull { key -> + (this[key] as? JsonPrimitive)?.booleanOrNull + } +} + +internal fun JsonObject.int(vararg keys: String): Int? { + return keys.firstNotNullOfOrNull { key -> + (this[key] as? JsonPrimitive)?.intOrNull + } +} + +internal fun JsonObject.firstLocationPath(): String? { + return (this["locations"] as? JsonArray) + ?.firstNotNullOfOrNull { (it as? JsonObject)?.string("path") } +} + +internal fun JsonObject.titlePath(): String? { + val title = string("title") ?: return null + val firstSpaceIndex = title.indexOf(' ') + if (firstSpaceIndex < 0 || firstSpaceIndex >= title.lastIndex) { + return null + } + val path = title.substring(firstSpaceIndex + 1).trim() + return path.takeIf { it.startsWith("/") } +} + +internal fun JsonObject.firstChangePath(): String? { + return (this["changes"] as? JsonObject)?.keys?.firstOrNull() +} + +internal fun JsonObject.firstChangeContent(): String? { + return (this["changes"] as? JsonObject)?.values?.firstNotNullOfOrNull { change -> + (change as? JsonObject)?.string("content") + } +} + +internal fun JsonElement?.asJsonArrayOrEmpty(): JsonArray { + return this as? JsonArray ?: JsonArray(emptyList()) +} + +internal fun JsonElement?.asJsonObjectOrNull(json: Json): JsonObject? { + return when (this) { + is JsonObject -> this + is JsonPrimitive -> { + if (!isString) { + return null + } + runCatching { json.parseToJsonElement(content) }.getOrNull() as? JsonObject + } + + else -> null + } +} + +internal fun JsonElement?.toPayloadString(): String { + return when (this) { + null -> "" + is JsonPrimitive -> if (isString) content else toString() + else -> toString() + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJsonRpcConnection.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJsonRpcConnection.kt new file mode 100644 index 00000000..c5a44c90 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJsonRpcConnection.kt @@ -0,0 +1,257 @@ +package ee.carlrobert.codegpt.agent.external + +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import ee.carlrobert.codegpt.settings.configuration.ConfigurationSettings +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.* +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +internal data class AcpJsonRpcRequest( + val id: JsonElement, + val method: String, + val params: JsonObject +) + +internal data class AcpJsonRpcNotification( + val method: String, + val params: JsonObject +) + +internal data class AcpJsonRpcError( + val code: Int, + val message: String, + val data: JsonElement? = null +) { + companion object { + const val METHOD_NOT_FOUND_CODE = -32601 + } +} + +internal class AcpJsonRpcException( + val error: AcpJsonRpcError +) : IllegalStateException(error.message) + +private sealed interface AcpJsonRpcIncomingMessage { + data class Response( + val id: String, + val result: JsonElement, + val error: AcpJsonRpcError? + ) : AcpJsonRpcIncomingMessage + + data class Request(val value: AcpJsonRpcRequest) : AcpJsonRpcIncomingMessage + + data class Notification(val value: AcpJsonRpcNotification) : AcpJsonRpcIncomingMessage +} + +internal class AcpJsonRpcConnection( + private val json: Json, + private val process: Process, + private val scope: CoroutineScope, + private val logger: Logger, + private val processName: String, + private val onRequest: suspend (AcpJsonRpcRequest) -> JsonElement?, + private val onNotification: suspend (AcpJsonRpcNotification) -> Unit +) { + + private val requestCounter = AtomicLong(0) + private val pendingResponses = ConcurrentHashMap>() + private val writeMutex = Mutex() + + fun isAlive(): Boolean = process.isAlive + + fun startReader() { + scope.launch { + val reader = process.inputStream.bufferedReader(StandardCharsets.UTF_8) + try { + while (isActive && process.isAlive) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + if (service().state.debugModeEnabled) { + logger.info("[$processName] $line") + } + + when (val message = parseIncomingMessage(line)) { + null -> Unit + is AcpJsonRpcIncomingMessage.Response -> handleResponse(message) + is AcpJsonRpcIncomingMessage.Request -> reply(message.value) + is AcpJsonRpcIncomingMessage.Notification -> onNotification(message.value) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (t: Throwable) { + logger.warn("ACP reader loop failed for $processName", t) + } finally { + closePendingResponses(IllegalStateException("$processName ACP process exited")) + } + } + } + + fun startStderrLogger() { + scope.launch { + process.errorStream.bufferedReader(StandardCharsets.UTF_8).useLines { lines -> + lines.forEach { line -> + if (line.isNotBlank()) { + logger.info("[$processName] $line") + } + } + } + } + } + + suspend fun request(method: String, params: JsonObject): JsonObject { + val id = requestCounter.incrementAndGet().toString() + val response = CompletableDeferred() + pendingResponses[id] = response + writePayload(requestPayload(id, method, params)) + return response.await().jsonObject + } + + suspend fun notify(method: String, params: JsonObject) { + writePayload(notificationPayload(method, params)) + } + + fun close() { + closePendingResponses(CancellationException("ACP session closed")) + process.destroy() + } + + private suspend fun reply(request: AcpJsonRpcRequest) { + val response = runCatching { onRequest(request) }.fold( + onSuccess = { body -> + if (body == null) { + errorResponse(request.id, -32601, "Method not found: ${request.method}") + } else { + successResponse(request.id, body) + } + }, + onFailure = { error -> + errorResponse(request.id, -32603, error.message ?: "Internal error") + } + ) + writePayload(response) + } + + private fun handleResponse(response: AcpJsonRpcIncomingMessage.Response) { + val pending = pendingResponses.remove(response.id) ?: return + if (response.error != null) { + pending.completeExceptionally(AcpJsonRpcException(response.error)) + return + } + pending.complete(response.result) + } + + private fun parseIncomingMessage(line: String): AcpJsonRpcIncomingMessage? { + val element = runCatching { json.parseToJsonElement(line) } + .onFailure { logger.warn("Ignoring non-JSON ACP output from $processName: $line") } + .getOrNull() ?: return null + val obj = element.jsonObject + return when { + obj["result"] != null || obj["error"] != null -> { + val responseId = obj["id"]?.jsonPrimitive?.content ?: return null + AcpJsonRpcIncomingMessage.Response( + id = responseId, + result = obj["result"] ?: JsonObject(emptyMap()), + error = parseError(obj["error"]) + ) + } + + obj["method"] != null && obj["id"] != null -> { + val method = obj["method"]?.jsonPrimitive?.content ?: return null + AcpJsonRpcIncomingMessage.Request( + AcpJsonRpcRequest( + id = obj["id"] ?: return null, + method = method, + params = obj["params"]?.jsonObject ?: JsonObject(emptyMap()) + ) + ) + } + + obj["method"] != null -> { + val method = obj["method"]?.jsonPrimitive?.content ?: return null + AcpJsonRpcIncomingMessage.Notification( + AcpJsonRpcNotification( + method = method, + params = obj["params"]?.jsonObject ?: JsonObject(emptyMap()) + ) + ) + } + + else -> null + } + } + + private fun parseError(element: JsonElement?): AcpJsonRpcError? { + val error = element as? JsonObject ?: return null + return AcpJsonRpcError( + code = error.int("code") ?: 0, + message = error.string("message") ?: "Unknown JSON-RPC error", + data = error["data"] + ) + } + + private suspend fun writePayload(payload: JsonObject) { + writeMutex.withLock { + val serializedPayload = json.encodeToString(JsonObject.serializer(), payload) + if (service().state.debugModeEnabled) { + logger.info("[$processName] $serializedPayload") + } + + process.outputStream.write( + (serializedPayload + "\n").toByteArray(StandardCharsets.UTF_8) + ) + process.outputStream.flush() + } + } + + private fun closePendingResponses(error: Throwable) { + pendingResponses.values.forEach { pending -> + pending.completeExceptionally(error) + } + pendingResponses.clear() + } + + private fun requestPayload(id: String, method: String, params: JsonObject): JsonObject { + return buildJsonObject { + put("jsonrpc", JsonPrimitive("2.0")) + put("id", JsonPrimitive(id)) + put("method", JsonPrimitive(method)) + put("params", params) + } + } + + private fun notificationPayload(method: String, params: JsonObject): JsonObject { + return buildJsonObject { + put("jsonrpc", JsonPrimitive("2.0")) + put("method", JsonPrimitive(method)) + put("params", params) + } + } + + private fun successResponse(id: JsonElement, result: JsonElement): JsonObject { + return buildJsonObject { + put("jsonrpc", JsonPrimitive("2.0")) + put("id", id) + put("result", result) + } + } + + private fun errorResponse(id: JsonElement, code: Int, message: String): JsonObject { + return buildJsonObject { + put("jsonrpc", JsonPrimitive("2.0")) + put("id", id) + put( + "error", + buildJsonObject { + put("code", JsonPrimitive(code)) + put("message", JsonPrimitive(message)) + } + ) + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpProcessHelper.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpProcessHelper.kt new file mode 100644 index 00000000..3d0d0a75 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpProcessHelper.kt @@ -0,0 +1,41 @@ +package ee.carlrobert.codegpt.agent.external + +import ee.carlrobert.codegpt.util.CommandRuntimeHelper + +object AcpProcessHelper { + + fun resolveCommand( + command: String, + extraEnvironment: Map = emptyMap() + ): String? { + return CommandRuntimeHelper.resolveCommand(command, extraEnvironment) + } + + fun createEnvironment( + extraEnvironment: Map, + resolvedCommand: String + ): MutableMap { + return CommandRuntimeHelper.createEnvironment( + extraEnvironment = extraEnvironment, + resolvedCommand = resolvedCommand + ) + } + + fun getCommandNotFoundMessage(command: String): String { + return buildString { + append("Command '$command' not found. ") + when (command) { + "npx", "node" -> { + append("Node.js/npm is required for this ACP runtime. ") + append("Ensure it is installed and available to the IDE process. ") + append("You can also point the runtime to an absolute executable path.") + } + + else -> { + append("Ensure it is installed and available to the IDE process. ") + append("You can also point the runtime to an absolute executable path.") + } + } + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionConfigAdapter.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionConfigAdapter.kt new file mode 100644 index 00000000..0f4b45b0 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionConfigAdapter.kt @@ -0,0 +1,266 @@ +package ee.carlrobert.codegpt.agent.external + +import ee.carlrobert.codegpt.toolwindow.agent.AcpConfigOption +import ee.carlrobert.codegpt.toolwindow.agent.AcpConfigOptionChoice +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* + +internal enum class AcpSessionConfigId( + val value: String, + val displayName: String +) { + MODEL("model", "Model"), + MODE("mode", "Mode"); + + fun matches(option: AcpConfigOption): Boolean { + return option.id == value || option.category == value + } +} + +internal data class AcpConfigUpdateRequest( + val sessionId: String, + val optionId: String, + val value: String +) { + fun toJsonObject(): JsonObject { + return buildJsonObject { + put("sessionId", sessionId) + put("configId", optionId) + put("value", value) + } + } +} + +internal enum class AcpConfigUpdateMethod(val wireName: String) { + SNAKE_CASE("session/set_config_option"), + CAMEL_CASE("session/setConfigOption") +} + +internal sealed interface AcpConfigUpdateSupport { + fun candidateMethods(): List + + data object Unknown : AcpConfigUpdateSupport { + override fun candidateMethods(): List = + AcpConfigUpdateMethod.entries + } + + data object Unsupported : AcpConfigUpdateSupport { + override fun candidateMethods(): List = emptyList() + } + + data class Supported(val method: AcpConfigUpdateMethod) : AcpConfigUpdateSupport { + override fun candidateMethods(): List = listOf(method) + } +} + +internal sealed interface AcpConfigUpdateResult { + data class Applied( + val response: JsonObject, + val support: AcpConfigUpdateSupport.Supported + ) : AcpConfigUpdateResult + + data object Unsupported : AcpConfigUpdateResult +} + +internal class AcpSessionConfigAdapter( + private val json: Json +) { + + fun merge( + existing: List, + response: JsonObject + ): List { + val updates = decode(response) + if (updates.isEmpty()) { + return existing + } + + val merged = existing.associateByTo(linkedMapOf()) { it.id } + updates.forEach { option -> + merged[option.id] = option + } + return merged.values.toList() + } + + suspend fun updateOption( + request: AcpConfigUpdateRequest, + support: AcpConfigUpdateSupport, + sendRequest: suspend (String, JsonObject) -> JsonObject + ): AcpConfigUpdateResult { + val candidateMethods = support.candidateMethods() + if (candidateMethods.isEmpty()) { + return AcpConfigUpdateResult.Unsupported + } + + val params = request.toJsonObject() + candidateMethods.forEach { method -> + try { + return AcpConfigUpdateResult.Applied( + response = sendRequest(method.wireName, params), + support = AcpConfigUpdateSupport.Supported(method) + ) + } catch (error: Throwable) { + if (!error.isMethodNotFoundJsonRpcError()) { + throw error + } + } + } + + return AcpConfigUpdateResult.Unsupported + } + + private fun decode(response: JsonObject): List { + val directOptions = buildList { + addAll( + response.decodeField>("configOptions") + .orEmpty() + .mapNotNull(AcpStandardConfigOptionPayload::toConfigOption) + ) + response.decodeField("configOption") + ?.toConfigOption() + ?.let(::add) + } + + val hasDirectModelOption = directOptions.any(AcpSessionConfigId.MODEL::matches) + val hasDirectModeOption = directOptions.any(AcpSessionConfigId.MODE::matches) + + return buildList { + addAll(directOptions) + if (!hasDirectModelOption) { + response.decodeField("models") + ?.toConfigOption() + ?.let(::add) + } + if (!hasDirectModeOption) { + response.decodeField("modes") + ?.toConfigOption() + ?.let(::add) + } + } + } + + private inline fun JsonObject.decodeField(key: String): T? { + val element = this[key] ?: return null + return runCatching { json.decodeFromJsonElement(element) }.getOrNull() + } +} + +@Serializable +private data class AcpStandardConfigOptionPayload( + val id: String? = null, + val name: String? = null, + val description: String? = null, + val category: String? = null, + val type: String? = null, + val currentValue: String? = null, + val current_value: String? = null, + val value: String? = null, + val options: List = emptyList() +) { + fun toConfigOption(): AcpConfigOption? { + val resolvedId = id.nullIfBlank() ?: return null + return AcpConfigOption( + id = resolvedId, + name = name.nullIfBlank() ?: resolvedId, + description = description.nullIfBlank(), + category = category.nullIfBlank(), + type = type.nullIfBlank(), + currentValue = firstNotBlank(currentValue, current_value, value), + options = options.mapNotNull(AcpConfigChoicePayload::toChoice) + ) + } +} + +@Serializable +private data class AcpConfigChoicePayload( + val value: String? = null, + val id: String? = null, + val name: String? = null, + val description: String? = null +) { + fun toChoice(): AcpConfigOptionChoice? { + val resolvedValue = firstNotBlank(value, id) ?: return null + return AcpConfigOptionChoice( + value = resolvedValue, + name = name.nullIfBlank() ?: resolvedValue, + description = description.nullIfBlank() + ) + } +} + +@Serializable +private data class AcpModelsPayload( + val currentModelId: String? = null, + val availableModels: List = emptyList() +) { + fun toConfigOption(): AcpConfigOption? { + return toAlternativeConfigOption( + id = AcpSessionConfigId.MODEL, + currentValue = currentModelId, + entries = availableModels + ) + } +} + +@Serializable +private data class AcpModesPayload( + val currentModeId: String? = null, + val availableModes: List = emptyList() +) { + fun toConfigOption(): AcpConfigOption? { + return toAlternativeConfigOption( + id = AcpSessionConfigId.MODE, + currentValue = currentModeId, + entries = availableModes + ) + } +} + +@Serializable +private data class AcpAlternativeChoicePayload( + val modelId: String? = null, + val modeId: String? = null, + val value: String? = null, + val id: String? = null, + val name: String? = null, + val description: String? = null +) { + fun toChoice(): AcpConfigOptionChoice? { + val resolvedValue = firstNotBlank(modelId, modeId, value, id) ?: return null + return AcpConfigOptionChoice( + value = resolvedValue, + name = name.nullIfBlank() ?: resolvedValue, + description = description.nullIfBlank() + ) + } +} + +private fun toAlternativeConfigOption( + id: AcpSessionConfigId, + currentValue: String?, + entries: List +): AcpConfigOption? { + val options = entries.mapNotNull(AcpAlternativeChoicePayload::toChoice) + val resolvedCurrentValue = currentValue.nullIfBlank() + if (options.isEmpty() && resolvedCurrentValue == null) { + return null + } + return AcpConfigOption( + id = id.value, + name = id.displayName, + category = id.value, + type = "select", + currentValue = resolvedCurrentValue, + options = options + ) +} + +private fun firstNotBlank(vararg values: String?): String? { + return values.firstNotNullOfOrNull(String?::nullIfBlank) +} + +private fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() } + +private fun Throwable.isMethodNotFoundJsonRpcError(): Boolean { + return (this as? AcpJsonRpcException)?.error?.code == AcpJsonRpcError.METHOD_NOT_FOUND_CODE +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt new file mode 100644 index 00000000..f27c3d69 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt @@ -0,0 +1,119 @@ +package ee.carlrobert.codegpt.agent.external + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +internal sealed interface AcpSessionUpdate { + data class TextChunk(val text: String) : AcpSessionUpdate + data class ThoughtChunk(val text: String) : AcpSessionUpdate + data class ToolCall( + val toolCall: AcpDecodedToolCall, + val status: AcpToolCallStatus?, + val rawOutput: JsonElement? + ) : AcpSessionUpdate + + data class ToolCallUpdate( + val toolCallId: String, + val status: AcpToolCallStatus, + val rawOutput: JsonElement? + ) : AcpSessionUpdate + + data class ConfigOptionUpdate(val update: JsonObject) : AcpSessionUpdate +} + +internal enum class AcpToolCallStatus(val wireValue: String) { + COMPLETED("completed"), + FAILED("failed"), + CANCELLED("cancelled"); + + val isTerminal: Boolean + get() = true + + companion object { + fun fromWireValue(value: String?): AcpToolCallStatus? { + return entries.firstOrNull { it.wireValue == value } + } + } +} + +internal class AcpSessionUpdateParser( + private val toolCallDecoder: AcpToolCallDecoder +) { + + fun parse(notification: AcpJsonRpcNotification): AcpSessionUpdate? { + if (notification.method != SESSION_UPDATE_METHOD) { + return null + } + + val update = notification.params["update"] as? JsonObject ?: return null + return when (SessionUpdateKind.fromWireValue(update.string("sessionUpdate"))) { + SessionUpdateKind.AGENT_MESSAGE_CHUNK -> parseAgentMessageChunk(update) + SessionUpdateKind.TOOL_CALL -> parseToolCall(update) + SessionUpdateKind.TOOL_CALL_UPDATE -> parseToolCallUpdate(update) + SessionUpdateKind.CONFIG_OPTION_UPDATE -> AcpSessionUpdate.ConfigOptionUpdate(update) + null -> null + } + } + + private fun parseAgentMessageChunk(update: JsonObject): AcpSessionUpdate? { + val content = update["content"] as? JsonObject ?: return null + return when (MessageChunkType.fromWireValue(content.string("type"))) { + MessageChunkType.TEXT -> + AcpSessionUpdate.TextChunk(content.string("text").orEmpty()) + + MessageChunkType.THOUGHT -> { + val text = content.string("thought").orEmpty() + text.takeIf { it.isNotBlank() }?.let(AcpSessionUpdate::ThoughtChunk) + } + + null -> null + } + } + + private fun parseToolCall(update: JsonObject): AcpSessionUpdate? { + val toolCall = toolCallDecoder.decodeToolCall(update) ?: return null + return AcpSessionUpdate.ToolCall( + toolCall = toolCall, + status = AcpToolCallStatus.fromWireValue(update.string("status")), + rawOutput = update["rawOutput"] ?: update["content"] + ) + } + + private fun parseToolCallUpdate(update: JsonObject): AcpSessionUpdate? { + val toolCallId = update.string("toolCallId") ?: return null + val status = AcpToolCallStatus.fromWireValue(update.string("status")) ?: return null + return AcpSessionUpdate.ToolCallUpdate( + toolCallId = toolCallId, + status = status, + rawOutput = update["rawOutput"] ?: update["content"] + ) + } + + private enum class SessionUpdateKind(val wireValue: String) { + AGENT_MESSAGE_CHUNK("agent_message_chunk"), + TOOL_CALL("tool_call"), + TOOL_CALL_UPDATE("tool_call_update"), + CONFIG_OPTION_UPDATE("config_option_update"); + + companion object { + fun fromWireValue(value: String?): SessionUpdateKind? { + return entries.firstOrNull { it.wireValue == value } + } + } + } + + private enum class MessageChunkType(val wireValue: String) { + TEXT("text"), + THOUGHT("thought"); + + companion object { + fun fromWireValue(value: String?): MessageChunkType? { + return entries.firstOrNull { it.wireValue == value } + } + } + } + + private companion object { + const val SESSION_UPDATE_METHOD = "session/update" + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt new file mode 100644 index 00000000..da9012e3 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt @@ -0,0 +1,336 @@ +package ee.carlrobert.codegpt.agent.external + +import ee.carlrobert.codegpt.agent.ToolSpecs +import ee.carlrobert.codegpt.agent.tools.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import java.nio.charset.StandardCharsets + +internal data class AcpDecodedToolCall( + val id: String, + val toolName: String, + val args: Any? +) + +internal data class AcpPermissionRequestData( + val rawTitle: String, + val toolName: String, + val parsedArgs: Any?, + val details: String, + val options: JsonArray +) + +private data class DiffContent( + val path: String, + val oldText: String?, + val newText: String +) + +private data class ResolvedToolCall( + val rawTitle: String, + val toolName: String, + val args: Any? +) + +internal class AcpToolCallDecoder( + private val json: Json +) { + + fun decodeToolCall(metadata: JsonObject): AcpDecodedToolCall? { + val toolCallId = metadata.string("toolCallId") ?: return null + val tool = resolveToolCall(metadata) + return AcpDecodedToolCall( + id = toolCallId, + toolName = tool.toolName, + args = tool.args + ) + } + + fun decodePermissionRequest(params: JsonObject): AcpPermissionRequestData { + val toolCall = params["toolCall"] as? JsonObject ?: JsonObject(emptyMap()) + val tool = resolveToolCall(toolCall, defaultTitle = "Allow action?") + return AcpPermissionRequestData( + rawTitle = tool.rawTitle, + toolName = tool.toolName, + parsedArgs = tool.args, + details = permissionDetails(toolCall), + options = params["options"].asJsonArrayOrEmpty() + ) + } + + fun decodeResult( + toolName: String, + args: Any?, + status: AcpToolCallStatus, + rawOutput: JsonElement? + ): Any? { + val payload = rawOutput.toPayloadString() + ToolSpecs.decodeResultOrNull(json, toolName, payload)?.let { return it } + + if (status == AcpToolCallStatus.COMPLETED) { + when (args) { + is McpTool.Args -> return McpTool.Result( + serverId = args.serverId, + serverName = args.serverName, + toolName = args.toolName, + success = true, + output = payload.ifBlank { "MCP tool completed" } + ) + + is EditTool.Args -> return EditTool.Result.Success( + filePath = args.filePath, + replacementsMade = 1, + message = "Edit completed" + ) + + is WriteTool.Args -> return WriteTool.Result.Success( + filePath = args.filePath, + bytesWritten = args.content.toByteArray(StandardCharsets.UTF_8).size, + isNewFile = false, + message = "Write completed" + ) + } + } + + if (status == AcpToolCallStatus.FAILED || status == AcpToolCallStatus.CANCELLED) { + val message = payload.ifBlank { "Tool ${status.wireValue}" } + when (args) { + is McpTool.Args -> return McpTool.Result.error( + toolName = args.toolName, + output = message, + serverId = args.serverId, + serverName = args.serverName + ) + + is EditTool.Args -> return EditTool.Result.Error(args.filePath, message) + is WriteTool.Args -> return WriteTool.Result.Error(args.filePath, message) + } + } + + return payload.ifBlank { null } + } + + private fun resolveToolCall( + metadata: JsonObject, + defaultTitle: String = "Tool" + ): ResolvedToolCall { + val rawTitle = metadata.string("title") ?: defaultTitle + val rawKind = metadata.string("kind") + val rawInput = metadata["rawInput"] + val initialToolName = normalizeToolName(rawTitle, rawKind, rawInput) + val parsedArgs = if (initialToolName == "MCP") { + decodeMcpArgs(rawTitle, rawInput) + } else { + decodeToolArgs(initialToolName, rawInput, metadata) + } + return ResolvedToolCall( + rawTitle = rawTitle, + toolName = resolveToolName(initialToolName, parsedArgs), + args = parsedArgs + ) + } + + private fun permissionDetails(toolCall: JsonObject): String { + return buildString { + (toolCall["locations"] as? JsonArray) + ?.mapNotNull { (it as? JsonObject)?.string("path") } + ?.takeIf { it.isNotEmpty() } + ?.let { paths -> + appendLine("Locations:") + paths.forEach { path -> appendLine(path) } + } + + toolCall["rawInput"]?.let { rawInput -> + if (isNotBlank()) { + appendLine() + } + appendLine("Input:") + append(rawInput.toString()) + } + }.ifBlank { toolCall.toString() } + } + + private fun normalizeToolName( + rawTitle: String, + kind: String?, + rawInput: JsonElement? + ): String { + val normalizedKind = kind?.lowercase().orEmpty() + val rawInputObject = rawInput.asJsonObjectOrNull(json) + + return when { + looksLikeMcpToolName(rawTitle) -> "MCP" + rawInputObject?.get("command") != null || rawInputObject?.get("cmd") != null -> "Bash" + normalizedKind == "execute" || normalizedKind == "terminal" || normalizedKind == "bash" -> "Bash" + normalizedKind == "edit" -> "Edit" + normalizedKind == "read" -> "Read" + normalizedKind == "search" -> "IntelliJSearch" + normalizedKind == "fetch" -> "WebFetch" + else -> rawTitle.ifBlank { kind ?: "Tool" } + } + } + + private fun resolveToolName(initialToolName: String, args: Any?): String { + return when (args) { + is McpTool.Args -> "MCP" + is WriteTool.Args -> "Write" + is EditTool.Args -> "Edit" + is ReadTool.Args -> "Read" + is IntelliJSearchTool.Args -> "IntelliJSearch" + is BashTool.Args -> "Bash" + else -> initialToolName + } + } + + private fun decodeToolArgs( + toolName: String, + rawInput: JsonElement?, + metadata: JsonObject? = null + ): Any? { + val payload = rawInput.toPayloadString() + ToolSpecs.decodeArgsOrNull(json, toolName, payload)?.let { return it } + + val obj = rawInput.asJsonObjectOrNull(json) ?: JsonObject(emptyMap()) + return when (toolName) { + "Edit" -> decodeEditOrWriteArgs(obj, metadata) ?: payload.ifBlank { null } + "Write" -> decodeWriteArgs(obj, metadata) ?: payload.ifBlank { null } + "Read" -> decodeReadArgs(obj, metadata) ?: payload.ifBlank { null } + "IntelliJSearch" -> decodeSearchArgs(obj) ?: payload.ifBlank { null } + "Bash" -> decodeBashArgs(obj) ?: payload.ifBlank { null } + else -> payload.ifBlank { null } + } + } + + private fun decodeEditOrWriteArgs(obj: JsonObject, metadata: JsonObject?): Any? { + decodeDiffContent(metadata)?.let { diff -> + return if (diff.oldText == null) { + WriteTool.Args( + filePath = diff.path, + content = diff.newText + ) + } else { + EditTool.Args( + filePath = diff.path, + oldString = diff.oldText, + newString = diff.newText, + shortDescription = metadata?.string("title") ?: "ACP edit", + replaceAll = false + ) + } + } + + decodeWriteArgs(obj, metadata)?.let { return it } + return decodeEditArgs(obj, metadata) + } + + private fun decodeEditArgs(obj: JsonObject, metadata: JsonObject? = null): EditTool.Args? { + val filePath = obj.string("file_path", "filePath", "path") ?: return null + val oldString = obj.string("old_string", "oldString", "old_text", "oldText") ?: return null + val newString = obj.string("new_string", "newString", "new_text", "newText") ?: return null + val shortDescription = obj.string("short_description", "shortDescription", "description") + ?: metadata?.string("title") + ?: "ACP edit" + val replaceAll = obj.boolean("replace_all", "replaceAll") ?: false + return EditTool.Args(filePath, oldString, newString, shortDescription, replaceAll) + } + + private fun decodeWriteArgs( + obj: JsonObject, + metadata: JsonObject? = null + ): WriteTool.Args? { + val filePath = obj.string("file_path", "filePath", "path") + ?: metadata?.firstLocationPath() + ?: metadata?.titlePath() + ?: obj.firstChangePath() + ?: return null + val content = obj.string("content", "text") + ?: obj.firstChangeContent() + ?: decodeDiffContent(metadata)?.takeIf { it.oldText == null }?.newText + ?: return null + return WriteTool.Args(filePath, content) + } + + private fun decodeReadArgs(obj: JsonObject, metadata: JsonObject? = null): ReadTool.Args? { + val filePath = obj.string("file_path", "filePath", "path") + ?: metadata?.firstLocationPath() + ?: metadata?.titlePath() + ?: return null + return ReadTool.Args( + filePath = filePath, + offset = obj.int("offset", "line") ?: metadata?.int("line"), + limit = obj.int("limit", "maxLinesCount") + ) + } + + private fun decodeSearchArgs(obj: JsonObject): IntelliJSearchTool.Args? { + val pattern = obj.string("pattern", "searchText", "query", "nameKeyword") ?: return null + return IntelliJSearchTool.Args( + pattern = pattern, + scope = obj.string("scope"), + path = obj.string("path", "directoryToSearch"), + fileType = obj.string("fileType", "fileMask"), + context = null, + caseSensitive = obj.boolean("caseSensitive"), + regex = obj.boolean("regex"), + wholeWords = null, + outputMode = null, + limit = obj.int("limit", "maxUsageCount", "fileCountLimit") + ) + } + + private fun decodeBashArgs(obj: JsonObject): BashTool.Args? { + val command = obj.commandString() ?: return null + return BashTool.Args( + command = command, + workingDirectory = obj.string("workingDirectory", "workdir", "cwd"), + timeout = obj.int("timeout") ?: 60_000, + description = obj.string("description", "title"), + runInBackground = obj.boolean("run_in_background", "runInBackground") + ) + } + + private fun decodeMcpArgs(rawTitle: String, rawInput: JsonElement?): McpTool.Args { + val obj = rawInput.asJsonObjectOrNull(json) ?: JsonObject(emptyMap()) + val callName = rawTitle.ifBlank { obj.string("tool_name", "toolName") ?: "unknown" } + val slashIndex = callName.indexOf('/') + val serverName = if (slashIndex > 0) callName.substring(0, slashIndex) else obj.string( + "server_name", + "serverName" + ) + val toolName = if (slashIndex > 0 && slashIndex < callName.length - 1) { + callName.substring(slashIndex + 1) + } else { + obj.string("tool_name", "toolName") ?: callName.ifBlank { "unknown" } + } + val arguments = (obj["arguments"] as? JsonObject)?.toMap() ?: obj.toMap() + return McpTool.Args( + toolName = toolName, + serverId = obj.string("server_id", "serverId"), + serverName = serverName, + arguments = arguments + ) + } + + private fun decodeDiffContent(metadata: JsonObject?): DiffContent? { + val diff = (metadata?.get("content") as? JsonArray) + ?.firstOrNull { entry -> + (entry as? JsonObject)?.string("type") == "diff" + } as? JsonObject + ?: return null + val path = diff.string("path") ?: return null + val newText = diff.string("newText", "new_text") ?: return null + val oldText = diff.string("oldText", "old_text") + return DiffContent(path, oldText, newText) + } + + private fun looksLikeMcpToolName(rawTitle: String): Boolean { + val candidate = rawTitle.trim() + if (candidate.isBlank() || ' ' in candidate || candidate.startsWith("/")) { + return false + } + val slashIndex = candidate.indexOf('/') + return slashIndex > 0 && slashIndex < candidate.length - 1 + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/history/AgentCheckpointHistoryService.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/history/AgentCheckpointHistoryService.kt index 98139e54..4e6a30ee 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/history/AgentCheckpointHistoryService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/history/AgentCheckpointHistoryService.kt @@ -6,6 +6,7 @@ import ai.koog.agents.snapshot.providers.file.JVMFilePersistenceStorageProvider import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -203,9 +204,14 @@ class AgentCheckpointHistoryService(project: Project) { } return agentIds.mapNotNull { agentId -> - runCatching { buildSummary(agentId) } - .onFailure { logger.warn("Failed to load checkpoints for agentId=$agentId", it) } - .getOrNull() + try { + buildSummary(agentId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + logger.warn("Failed to load checkpoints for agentId=$agentId", throwable) + null + } }.sortedByDescending { it.latestCreatedAt } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpCommandValidator.kt b/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpCommandValidator.kt index e5f60062..9211d48c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpCommandValidator.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpCommandValidator.kt @@ -1,118 +1,15 @@ package ee.carlrobert.codegpt.mcp -import com.intellij.execution.configurations.PathEnvironmentVariableUtil -import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.util.SystemInfo -import java.io.File +import ee.carlrobert.codegpt.util.CommandRuntimeHelper object McpCommandValidator { - private val logger = thisLogger() - - fun resolveCommand(command: String): String? { - val commandFile = File(command) - if (commandFile.isAbsolute && commandFile.exists() && commandFile.canExecute()) { - return command - } - - return when { - command == "npx" || command == "node" -> findNodeExecutable(command) - else -> PathEnvironmentVariableUtil.findInPath(command)?.absolutePath - } - } - - private fun findNodeExecutable(command: String): String? { - PathEnvironmentVariableUtil.findInPath(command)?.let { - return it.absolutePath - } - - findInCommonMacOSLocations(command)?.let { - return it - } - - findViaNodeVersionManager(command)?.let { - return it - } - - findViaEnvironmentHints(command)?.let { - return it - } - - logger.warn("$command not found in any location") - return null - } - - private fun findInCommonMacOSLocations(command: String): String? { - if (!SystemInfo.isMac) { - return null - } - - val commonLocations = listOf( - "/usr/local/bin", // Homebrew (Intel Mac) - "/opt/homebrew/bin", // Homebrew (Apple Silicon) - "/usr/bin", // System Node.js - "/usr/local/share/npm/bin", // npm global bin - "/opt/homebrew/share/npm/bin" // npm global bin (Apple Silicon) - ) - - for (location in commonLocations) { - findExecutableInDirectory(File(location), command)?.let { return it } - } - - return null - } - - private fun findViaNodeVersionManager(command: String): String? { - val userHome = System.getProperty("user.home") ?: return null - - System.getenv("NVM_DIR")?.let { nvmDir -> - findExecutableInDirectory(File(nvmDir, "current/bin"), command)?.let { return it } - } - findExecutableInDirectory(File(userHome, ".nvm/current/bin"), command)?.let { return it } - System.getenv("VOLTA_HOME")?.let { voltaHome -> - findExecutableInDirectory(File(voltaHome, "bin"), command)?.let { return it } - } - findExecutableInDirectory(File(userHome, ".volta/bin"), command)?.let { return it } - System.getenv("FNM_DIR")?.let { fnmDir -> - findExecutableInDirectory(File(fnmDir), command)?.let { return it } - } - - return null - } - - private fun findViaEnvironmentHints(command: String): String? { - System.getenv("NODE_PATH")?.let { nodePath -> - File(nodePath).parent?.let { binDir -> - findExecutableInDirectory(File(binDir), command)?.let { return it } - } - } - System.getenv("NPM_CONFIG_PREFIX")?.let { prefix -> - findExecutableInDirectory(File(prefix, "bin"), command)?.let { return it } - } - - return null - } - - private fun findExecutableInDirectory(directory: File, command: String): String? { - if (!directory.exists() || !directory.isDirectory) { - return null - } - - val executable = File(directory, command) - if (executable.exists() && executable.canExecute()) { - return executable.absolutePath - } - - if (SystemInfo.isWindows) { - for (ext in listOf(".exe", ".cmd", ".bat")) { - val executableWithExt = File(directory, command + ext) - if (executableWithExt.exists() && executableWithExt.canExecute()) { - return executableWithExt.absolutePath - } - } - } - - return null + fun resolveCommand( + command: String, + extraEnvironment: Map = emptyMap() + ): String? { + return CommandRuntimeHelper.resolveCommand(command, extraEnvironment) } fun getCommandNotFoundMessage(command: String): String { @@ -145,4 +42,4 @@ object McpCommandValidator { } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpPathHelper.kt b/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpPathHelper.kt index 43665df4..c85819d9 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpPathHelper.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpPathHelper.kt @@ -1,99 +1,17 @@ package ee.carlrobert.codegpt.mcp -import com.intellij.openapi.diagnostic.thisLogger -import java.io.File +import ee.carlrobert.codegpt.util.CommandRuntimeHelper object McpPathHelper { - private val logger = thisLogger() - - fun getAdditionalNodePaths(): List { - val osName = System.getProperty("os.name").lowercase() - val userHome = System.getProperty("user.home") - val additionalPaths = mutableListOf() - - when { - osName.contains("mac") -> { - additionalPaths.addAll(listOf( - "/usr/local/bin", - "/opt/homebrew/bin", - "/usr/bin", - "/usr/local/share/npm/bin", - "/opt/homebrew/share/npm/bin", - - "$userHome/.nvm/current/bin", - "$userHome/.nvm/versions/node/current/bin", - "$userHome/.volta/bin" - )) - additionalPaths.addAll(getShellProfilePaths()) - } - - osName.contains("windows") -> additionalPaths.addAll(listOf( - "C:\\Program Files\\nodejs", - "${System.getenv("APPDATA")}\\npm" - )) - - else -> additionalPaths.addAll(listOf( - "/usr/local/bin", - "/usr/bin", - "$userHome/.nvm/current/bin", - "$userHome/.nvm/versions/node/current/bin" - )) - } - return additionalPaths.distinct() - } - - private fun getShellProfilePaths(): List { - val userHome = System.getProperty("user.home") ?: return emptyList() - val shellProfiles = listOf( - File(userHome, ".zshrc"), - File(userHome, ".bash_profile"), - File(userHome, ".bashrc"), - File(userHome, ".profile"), - File(userHome, ".zprofile") + fun createEnvironment( + serverEnvironmentVariables: Map, + resolvedCommand: String? = null + ): MutableMap { + return CommandRuntimeHelper.createEnvironment( + extraEnvironment = serverEnvironmentVariables, + resolvedCommand = resolvedCommand, + includeResolvedCommandParent = false ) - - val pathEntries = mutableSetOf() - - for (profile in shellProfiles) { - if (profile.exists()) { - try { - val content = profile.readText() - val pathRegex = Regex("""(?:export\s+)?PATH\s*=\s*["']?([^"':]+)(?::[^"']*)?["']?""") - pathRegex.findAll(content).forEach { match -> - val pathValue = match.groupValues[1] - pathValue.split(":").forEach { path -> - if (path.isNotBlank()) { - pathEntries.add(path) - } - } - } - } catch (e: Exception) { - logger.error("Failed to read shell profile ${profile.absolutePath}: ${e.message}") - } - } - } - - return pathEntries.toList() - } - - fun createEnvironmentPath(environment: MutableMap): MutableMap { - val currentPath = environment["PATH"] ?: "" - val additionalPaths = getAdditionalNodePaths() - val pathSeparator = File.pathSeparator - - val enhancedPath = (listOf(currentPath) + additionalPaths) - .filter { it.isNotEmpty() } - .joinToString(pathSeparator) - - environment["PATH"] = enhancedPath - return environment - } - - fun createEnvironment(serverEnvironmentVariables: Map): MutableMap { - val mergedEnv = mutableMapOf() - mergedEnv.putAll(System.getenv()) - mergedEnv.putAll(serverEnvironmentVariables) - return createEnvironmentPath(mergedEnv) } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpSessionManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpSessionManager.kt index 79f9719b..27c9f42c 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpSessionManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/mcp/McpSessionManager.kt @@ -37,7 +37,10 @@ class McpSessionManager { ?: throw IllegalArgumentException("Server with ID $serverId not found") val command = serverDetails.command ?: "npx" - val resolvedCommand = McpCommandValidator.resolveCommand(command) + val resolvedCommand = McpCommandValidator.resolveCommand( + command = command, + extraEnvironment = serverDetails.environmentVariables + ) if (resolvedCommand == null) { val errorMsg = McpCommandValidator.getCommandNotFoundMessage(command) logger.error( @@ -47,7 +50,7 @@ class McpSessionManager { } val mergedEnv = - McpPathHelper.createEnvironment(serverDetails.environmentVariables) + McpPathHelper.createEnvironment(serverDetails.environmentVariables, resolvedCommand) val serverParameters = ServerParameters.builder(resolvedCommand) .args(*serverDetails.arguments.toTypedArray()) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentConfigurable.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentConfigurable.kt new file mode 100644 index 00000000..386cd643 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentConfigurable.kt @@ -0,0 +1,27 @@ +package ee.carlrobert.codegpt.settings.agents.acp + +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.project.Project +import javax.swing.JComponent + +class AcpAgentConfigurable(private val project: Project) : Configurable { + + private lateinit var form: AcpAgentSettingsForm + + override fun getDisplayName(): String = "ProxyAI: ACP" + + override fun createComponent(): JComponent { + form = AcpAgentSettingsForm(project) + return form.createPanel() + } + + override fun isModified(): Boolean = form.isModified() + + override fun apply() { + form.applyChanges() + } + + override fun reset() { + form.resetChanges() + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettings.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettings.kt new file mode 100644 index 00000000..57d3879d --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettings.kt @@ -0,0 +1,34 @@ +package ee.carlrobert.codegpt.settings.agents.acp + +import com.intellij.openapi.components.* +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgentPreset +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgents + +@Service(Service.Level.PROJECT) +@State( + name = "CodeGPT_AcpAgentSettings", + storages = [Storage("CodeGPT_AcpAgentSettings.xml")] +) +class AcpAgentSettings : + SimplePersistentStateComponent( + AcpAgentSettingsState().apply { + enabledAgentIds = ExternalAcpAgents.enabledByDefaultIds().toMutableList() + } + ) { + + fun getEnabledPresetIds(): List = state.enabledAgentIds + + fun getVisiblePresets(currentPresetId: String? = null): List { + val visibleIds = state.enabledAgentIds.toMutableSet() + currentPresetId?.takeIf { it.isNotBlank() }?.let(visibleIds::add) + return ExternalAcpAgents.all().filter { it.id in visibleIds } + } + + fun setEnabledPresetIds(ids: Collection) { + state.enabledAgentIds = ids.distinct().toMutableList() + } +} + +class AcpAgentSettingsState : BaseState() { + var enabledAgentIds by list() +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettingsForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettingsForm.kt new file mode 100644 index 00000000..b9982ed9 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettingsForm.kt @@ -0,0 +1,238 @@ +package ee.carlrobert.codegpt.settings.agents.acp + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.table.JBTable +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import ee.carlrobert.codegpt.agent.external.AcpIcons +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgents +import java.awt.Component +import java.awt.Dimension +import javax.swing.JComponent +import javax.swing.JTable +import javax.swing.event.DocumentEvent +import javax.swing.table.AbstractTableModel +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.table.TableRowSorter + +class AcpAgentSettingsForm(project: Project) { + + private val settings = project.service() + private val selectedPresetIds = linkedSetOf() + private val presets = ExternalAcpAgents.all() + private val root = BorderLayoutPanel() + private val searchField = SearchTextField().apply { + textEditor.emptyText.text = "Search ACP runtimes..." + border = JBUI.Borders.compound( + JBUI.Borders.customLine(UIUtil.getTooltipSeparatorColor(), 1), + JBUI.Borders.empty(3, 8) + ) + textEditor.border = JBUI.Borders.empty() + } + private val tableModel = AcpAgentTableModel() + private val rowSorter = TableRowSorter(tableModel) + private val helperLabel = JBLabel( + "ACP runtimes are external agents that can appear in the Agent runtime dropdown." + ).apply { + font = JBFont.small() + foreground = UIUtil.getContextHelpForeground() + border = JBUI.Borders.emptyTop(6) + } + private val table = JBTable(tableModel).apply { + rowSorter = this@AcpAgentSettingsForm.rowSorter + emptyText.text = "No ACP runtimes match your search." + setShowGrid(false) + intercellSpacing = Dimension(0, 0) + rowHeight = JBUI.scale(28) + fillsViewportHeight = true + tableHeader.reorderingAllowed = false + tableHeader.resizingAllowed = true + autoResizeMode = JTable.AUTO_RESIZE_LAST_COLUMN + setSelectionMode(javax.swing.ListSelectionModel.SINGLE_SELECTION) + setDefaultRenderer(String::class.java, AcpTableCellRenderer()) + } + + init { + buildUi() + configureTable() + installSearch() + resetChanges() + } + + fun createPanel(): JComponent = root + + fun isModified(): Boolean { + return selectedPresetIds != settings.getEnabledPresetIds().toSet() + } + + fun applyChanges() { + settings.setEnabledPresetIds(selectedPresetIds) + } + + fun resetChanges() { + selectedPresetIds.clear() + selectedPresetIds += settings.getEnabledPresetIds() + tableModel.fireTableDataChanged() + refreshFilter() + } + + private fun buildUi() { + val scrollPane = JBScrollPane(table).apply { + border = JBUI.Borders.customLine(UIUtil.getTooltipSeparatorColor(), 1) + horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER + } + + root.border = JBUI.Borders.empty(8) + root.addToTop( + BorderLayoutPanel().apply { + border = JBUI.Borders.emptyBottom(8) + addToTop(searchField) + addToCenter(helperLabel) + } + ) + root.addToCenter(scrollPane) + } + + private fun configureTable() { + rowSorter.setSortable(COL_ENABLED, false) + rowSorter.setSortable(COL_NAME, false) + rowSorter.setSortable(COL_VENDOR, false) + rowSorter.setSortable(COL_COMMAND, false) + + table.columnModel.getColumn(COL_ENABLED).apply { + minWidth = JBUI.scale(36) + maxWidth = JBUI.scale(36) + preferredWidth = JBUI.scale(36) + } + table.columnModel.getColumn(COL_NAME).preferredWidth = JBUI.scale(170) + table.columnModel.getColumn(COL_VENDOR).preferredWidth = JBUI.scale(90) + table.columnModel.getColumn(COL_COMMAND).preferredWidth = JBUI.scale(360) + } + + private fun installSearch() { + searchField.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + refreshFilter() + } + }) + } + + private fun refreshFilter() { + val query = searchField.text.trim().lowercase() + rowSorter.rowFilter = if (query.isBlank()) { + null + } else { + object : javax.swing.RowFilter() { + override fun include(entry: Entry): Boolean { + val preset = presets[entry.identifier] + return listOf( + preset.displayName, + preset.vendor, + preset.description.orEmpty(), + preset.fullCommand() + ).joinToString(" ").lowercase().contains(query) + } + } + } + } + + private inner class AcpAgentTableModel : AbstractTableModel() { + + override fun getRowCount(): Int = presets.size + + override fun getColumnCount(): Int = 4 + + override fun getColumnName(column: Int): String { + return when (column) { + COL_ENABLED -> "" + COL_NAME -> "Agent" + COL_VENDOR -> "Vendor" + COL_COMMAND -> "Command" + else -> "" + } + } + + override fun getColumnClass(columnIndex: Int): Class<*> { + return if (columnIndex == COL_ENABLED) { + java.lang.Boolean::class.java + } else { + String::class.java + } + } + + override fun isCellEditable(rowIndex: Int, columnIndex: Int): Boolean { + return columnIndex == COL_ENABLED + } + + override fun getValueAt(rowIndex: Int, columnIndex: Int): Any { + val preset = presets[rowIndex] + return when (columnIndex) { + COL_ENABLED -> preset.id in selectedPresetIds + COL_NAME -> preset.displayName + COL_VENDOR -> preset.vendor + COL_COMMAND -> preset.fullCommand() + else -> "" + } + } + + override fun setValueAt(aValue: Any?, rowIndex: Int, columnIndex: Int) { + if (columnIndex != COL_ENABLED) { + return + } + val preset = presets[rowIndex] + val enabled = aValue as? Boolean ?: false + if (enabled) { + selectedPresetIds.add(preset.id) + } else { + selectedPresetIds.remove(preset.id) + } + fireTableCellUpdated(rowIndex, columnIndex) + } + } + + private inner class AcpTableCellRenderer : DefaultTableCellRenderer() { + + override fun getTableCellRendererComponent( + table: JTable, + value: Any?, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { + val component = + super.getTableCellRendererComponent(table, value, isSelected, false, row, column) + val modelRow = table.convertRowIndexToModel(row) + val preset = presets[modelRow] + horizontalAlignment = LEFT + border = JBUI.Borders.empty(0, 6) + icon = if (column == COL_NAME) AcpIcons.iconFor(preset.id) else null + iconTextGap = JBUI.scale(8) + font = if (column == COL_NAME) { + JBFont.label().asBold() + } else { + JBFont.small() + } + foreground = when { + isSelected -> table.selectionForeground + column == COL_NAME -> UIUtil.getLabelForeground() + else -> UIUtil.getContextHelpForeground() + } + return component + } + } + + private companion object { + const val COL_ENABLED = 0 + const val COL_NAME = 1 + const val COL_VENDOR = 2 + const val COL_COMMAND = 3 + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt index 7db954ad..10459cde 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/mcp/McpClientManager.kt @@ -19,10 +19,14 @@ class McpClientManager { fun createClient(serverDetails: McpServerDetailsState): McpSyncClient? { return try { val command = serverDetails.command ?: "npx" - val resolvedCommand = McpCommandValidator.resolveCommand(command) + val resolvedCommand = McpCommandValidator.resolveCommand( + command = command, + extraEnvironment = serverDetails.environmentVariables + ) ?: throw IllegalStateException(McpCommandValidator.getCommandNotFoundMessage(command)) - val enhancedEnv = McpPathHelper.createEnvironment(serverDetails.environmentVariables) + val enhancedEnv = + McpPathHelper.createEnvironment(serverDetails.environmentVariables, resolvedCommand) val connectionParams = ServerParameters.builder(resolvedCommand) .args(*serverDetails.arguments.toTypedArray()) .env(enhancedEnv) @@ -57,7 +61,10 @@ class McpClientManager { return try { val command = serverDetails.command ?: "npx" - val resolvedCommand = McpCommandValidator.resolveCommand(command) + val resolvedCommand = McpCommandValidator.resolveCommand( + command = command, + extraEnvironment = serverDetails.environmentVariables + ) if (resolvedCommand == null) { logger.warn("Command not found for '${serverDetails.name}': $command") return ConnectionTestResult( diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/SettingsModelComboBoxAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/SettingsModelComboBoxAction.kt index 38881cae..f56e4e67 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/models/SettingsModelComboBoxAction.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/models/SettingsModelComboBoxAction.kt @@ -18,6 +18,7 @@ import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.service.ServiceType import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings import ee.carlrobert.codegpt.toolwindow.ui.ModelListPopup +import ee.carlrobert.codegpt.toolwindow.ui.ModelListPopups import java.awt.Color import javax.swing.Icon import javax.swing.JComponent @@ -190,18 +191,7 @@ class SettingsModelComboBoxAction( group: DefaultActionGroup, context: DataContext, disposeCallback: Runnable? - ): JBPopup { - val popup = ModelListPopup(group, context) - if (disposeCallback != null) { - popup.addListener(object : JBPopupListener { - override fun onClosed(event: LightweightWindowEvent) { - disposeCallback.run() - } - }) - } - popup.isShowSubmenuOnHover = true - return popup - } + ): JBPopup = ModelListPopups.createPopup(group, context, disposeCallback) private fun getModelsForFeature(featureType: FeatureType): List { val allModels = service().getAvailableModels(featureType) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSession.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSession.kt index 4ad157a6..18cbfb02 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSession.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentSession.kt @@ -5,6 +5,22 @@ import ee.carlrobert.codegpt.agent.history.CheckpointRef import ee.carlrobert.codegpt.conversations.Conversation import ee.carlrobert.codegpt.settings.service.ServiceType +data class AcpConfigOptionChoice( + val value: String, + val name: String, + val description: String? = null +) + +data class AcpConfigOption( + val id: String, + val name: String, + val description: String? = null, + val category: String? = null, + val type: String? = null, + val currentValue: String? = null, + val options: List = emptyList() +) + /** * Represents a single Agent session with its own conversation state and metadata. * Each tab in the Agent tool window corresponds to one AgentSession. @@ -14,9 +30,15 @@ data class AgentSession( val conversation: Conversation, var displayName: String = "", var serviceType: ServiceType? = null, + var externalAgentId: String? = null, + var externalAgentSessionId: String? = null, + var externalAgentConfigOptions: List = emptyList(), + var externalAgentConfigLoading: Boolean = false, var runtimeAgentId: String? = null, var resumeCheckpointRef: CheckpointRef? = null, val referencedFiles: List = emptyList(), val createdAt: Long = System.currentTimeMillis(), var lastActiveAt: Long = System.currentTimeMillis() -) +) { + var externalAgentErrorMessage: String? = null +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowPanel.kt index cbf9ba16..7e002129 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowPanel.kt @@ -85,6 +85,7 @@ class AgentToolWindowPanel( fun getTabbedPane(): AgentToolWindowTabbedPane = tabbedPane private fun showTabsView() { + disposeLandingPanel() centerLayout.show(centerPanel, TABS_CARD) } @@ -107,7 +108,8 @@ class AgentToolWindowPanel( project = project, agentSession = draftSession, draftSubmitHandler = { message -> - val panel = contentManager.createNewAgentTab() + disposeLandingPanel() + val panel = contentManager.createNewAgentTab(draftSession) panel.submitMessage(message) } ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt index 04eb8868..8d3fbc1e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabPanel.kt @@ -1,5 +1,7 @@ package ee.carlrobert.codegpt.toolwindow.agent +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.EDT @@ -15,6 +17,8 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import ee.carlrobert.codegpt.CodeGPTBundle import ee.carlrobert.codegpt.agent.* +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgents +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgentService import ee.carlrobert.codegpt.agent.ProxyAIAgent.loadProjectInstructions import ee.carlrobert.codegpt.agent.history.AgentCheckpointHistoryService import ee.carlrobert.codegpt.agent.history.AgentCheckpointTurnSequencer @@ -27,6 +31,8 @@ import ee.carlrobert.codegpt.psistructure.PsiStructureProvider import ee.carlrobert.codegpt.settings.service.FeatureType import ee.carlrobert.codegpt.settings.models.ModelSettings import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentToolWindowLandingPanel +import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentModelComboBoxAction +import ee.carlrobert.codegpt.toolwindow.agent.ui.AgentRuntimeOptionsComboBoxAction import ee.carlrobert.codegpt.toolwindow.agent.ui.RollbackPanel import ee.carlrobert.codegpt.toolwindow.agent.ui.TodoListPanel import ee.carlrobert.codegpt.toolwindow.agent.ui.ToolCallCard @@ -43,6 +49,7 @@ import ee.carlrobert.codegpt.ui.components.TokenUsageCounterPanel import ee.carlrobert.codegpt.ui.queue.QueuedMessagePanel import ee.carlrobert.codegpt.ui.textarea.UserInputPanel import ee.carlrobert.codegpt.ui.textarea.header.tag.TagManager +import ee.carlrobert.codegpt.ui.OverlayUtil import ee.carlrobert.codegpt.util.EditorUtil import ee.carlrobert.codegpt.util.StringUtil.stripThinkingBlocks import ee.carlrobert.codegpt.util.coroutines.CoroutineDispatchers @@ -122,9 +129,15 @@ class AgentToolWindowTabPanel( onStop = ::handleCancel, withRemovableSelectedEditorTag = true, agentTokenCounterPanel = TokenUsageCounterPanel(project, sessionId), + agentTokenCounterVisibilityProvider = { agentSession.externalAgentId.isNullOrBlank() }, sessionIdProvider = { sessionId }, conversationIdProvider = { conversation.id }, - onStartSessionTimeline = ::showSessionStartTimelineDialog + onStartSessionTimeline = ::showSessionStartTimelineDialog, + modelSelectorComponentFactory = ::createAgentModelSelector, + secondaryFooterComponentFactory = ::createAgentRuntimeOptionsSelector, + secondaryFooterComponentVisibilityProvider = { !agentSession.externalAgentId.isNullOrBlank() }, + promptEnhancerVisibilityProvider = { agentSession.externalAgentId.isNullOrBlank() }, + sessionTimelineVisibilityProvider = { agentSession.externalAgentId.isNullOrBlank() } ) private var rollbackPanel: RollbackPanel private val todoListPanel = TodoListPanel() @@ -382,6 +395,105 @@ class AgentToolWindowTabPanel( } } + private fun createAgentModelSelector(inputPanel: UserInputPanel): JComponent { + val modelSettings = ModelSettings.getInstance() + return AgentModelComboBoxAction( + project, + agentSession, + { + inputPanel.refreshModelDependentState() + }, + { externalAgentId -> + project.service().closeSession(sessionId) + agentSession.externalAgentId = externalAgentId + agentSession.externalAgentSessionId = null + agentSession.externalAgentConfigOptions = emptyList() + agentSession.externalAgentErrorMessage = null + agentSession.externalAgentConfigLoading = !externalAgentId.isNullOrBlank() + if (!externalAgentId.isNullOrBlank()) { + backgroundScope.launch { + runCatching { + project.service().warmUpSession(agentSession) + }.onFailure { ex -> + agentSession.externalAgentConfigLoading = false + project.service().closeSession(sessionId) + agentSession.externalAgentErrorMessage = + buildExternalAgentFailureMessage(externalAgentId, ex) + OverlayUtil.showNotification( + "${displayExternalAgentName(externalAgentId)} unavailable. ${agentSession.externalAgentErrorMessage}", + NotificationType.ERROR + ) + } + withContext(Dispatchers.EDT) { + inputPanel.refreshModelDependentState() + } + } + } + }, + modelSettings.getServiceForFeature(FeatureType.AGENT), + modelSettings.getAvailableProviders(FeatureType.AGENT), + true + ).createCustomComponent(com.intellij.openapi.actionSystem.ActionPlaces.UNKNOWN) + } + + private fun displayExternalAgentName(externalAgentId: String): String { + return ExternalAcpAgents.find(externalAgentId)?.displayName ?: externalAgentId + } + + private fun buildExternalAgentFailureMessage( + externalAgentId: String, + throwable: Throwable + ): String { + val command = ExternalAcpAgents.find(externalAgentId)?.command ?: externalAgentId + val message = throwable.message.orEmpty() + return when { + message.contains("Cannot run program", ignoreCase = true) && + message.contains("No such file or directory", ignoreCase = true) -> + "Command not found: $command" + + message.isNotBlank() -> message + else -> "Failed to start ${displayExternalAgentName(externalAgentId)}" + } + } + + private fun buildExternalAgentConfigFailureMessage(throwable: Throwable): String { + val message = throwable.message.orEmpty() + return when { + message.contains("does not support runtime option changes", ignoreCase = true) -> + "This agent exposes runtime info but does not support changing it over ACP." + + message.isNotBlank() -> message + else -> "Failed to update runtime option" + } + } + + private fun createAgentRuntimeOptionsSelector(inputPanel: UserInputPanel): JComponent { + return AgentRuntimeOptionsComboBoxAction( + agentSession, + { optionId, value -> + backgroundScope.launch { + agentSession.externalAgentConfigLoading = true + withContext(Dispatchers.EDT) { + inputPanel.refreshModelDependentState() + } + runCatching { + project.service() + .setSessionConfigOption(agentSession, optionId, value) + }.onFailure { ex -> + agentSession.externalAgentConfigLoading = false + OverlayUtil.showNotification( + "${displayExternalAgentName(agentSession.externalAgentId ?: "agent")} option update failed. ${buildExternalAgentConfigFailureMessage(ex)}", + NotificationType.ERROR + ) + } + withContext(Dispatchers.EDT) { + inputPanel.refreshModelDependentState() + } + } + } + ).createCustomComponent(com.intellij.openapi.actionSystem.ActionPlaces.UNKNOWN) + } + private fun displayLandingView() { disposeLandingPanelIfPresent() val landingPanel = createLandingView() diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabbedPane.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabbedPane.kt index 687cd2a0..88e373de 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabbedPane.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/AgentToolWindowTabbedPane.kt @@ -330,12 +330,15 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(), fun resetCurrentlyActiveTabPanel() { tryFindActiveTabPanel().ifPresent { tabPanel -> - val oldDisplayName = tabPanel.getAgentSession().displayName + val oldSession = tabPanel.getAgentSession() + val oldDisplayName = oldSession.displayName closeTabAt(selectedIndex) val newSession = AgentSession( UUID.randomUUID().toString(), Conversation(), - displayName = oldDisplayName + displayName = oldDisplayName, + serviceType = oldSession.serviceType, + externalAgentId = oldSession.externalAgentId ) project.service().createNewAgentTab(newSession) repaint() @@ -423,7 +426,8 @@ class AgentToolWindowTabbedPane(private val project: Project) : JBTabbedPane(), activeTabMapping.entries .filter { it.key != selectedPopupTabTitle } .forEach { entry -> - project.service().removeSession(entry.value.getSessionId()) + project.service() + .removeSession(entry.value.getSessionId()) Disposer.dispose(entry.value) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentModelComboBoxAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentModelComboBoxAction.kt new file mode 100644 index 00000000..ab70e611 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentModelComboBoxAction.kt @@ -0,0 +1,509 @@ +package ee.carlrobert.codegpt.toolwindow.agent.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ComboBoxAction +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopup +import ee.carlrobert.codegpt.Icons +import ee.carlrobert.codegpt.agent.external.AcpIcons +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgentPreset +import ee.carlrobert.codegpt.agent.external.ExternalAcpAgents +import ee.carlrobert.codegpt.completions.llama.LlamaModel +import ee.carlrobert.codegpt.settings.GeneralSettingsConfigurable +import ee.carlrobert.codegpt.settings.agents.acp.AcpAgentSettings +import ee.carlrobert.codegpt.settings.models.ModelSelection +import ee.carlrobert.codegpt.settings.models.ModelSettings +import ee.carlrobert.codegpt.settings.service.FeatureType +import ee.carlrobert.codegpt.settings.service.ModelChangeNotifier +import ee.carlrobert.codegpt.settings.service.ModelChangeNotifierAdapter +import ee.carlrobert.codegpt.settings.service.ServiceType +import ee.carlrobert.codegpt.settings.service.ServiceType.ANTHROPIC +import ee.carlrobert.codegpt.settings.service.ServiceType.CUSTOM_OPENAI +import ee.carlrobert.codegpt.settings.service.ServiceType.GOOGLE +import ee.carlrobert.codegpt.settings.service.ServiceType.INCEPTION +import ee.carlrobert.codegpt.settings.service.ServiceType.LLAMA_CPP +import ee.carlrobert.codegpt.settings.service.ServiceType.MISTRAL +import ee.carlrobert.codegpt.settings.service.ServiceType.OLLAMA +import ee.carlrobert.codegpt.settings.service.ServiceType.OPENAI +import ee.carlrobert.codegpt.settings.service.ServiceType.PROXYAI +import ee.carlrobert.codegpt.settings.service.llama.LlamaSettings +import ee.carlrobert.codegpt.settings.service.ollama.OllamaSettings +import ee.carlrobert.codegpt.toolwindow.agent.AgentSession +import ee.carlrobert.codegpt.toolwindow.ui.CodeGPTModelsListPopupAction +import ee.carlrobert.codegpt.toolwindow.ui.ModelListPopups +import java.awt.Color +import javax.swing.Icon +import javax.swing.JComponent + +class AgentModelComboBoxAction( + private val project: Project, + private val agentSession: AgentSession, + private val onModelChange: (ServiceType) -> Unit, + private val onAgentRuntimeChanged: (String?) -> Unit, + selectedProvider: ServiceType, + private val availableProviders: List, + private val showConfigureModels: Boolean +) : ComboBoxAction() { + + private data class TemplateState( + val icon: Icon, + val text: String + ) + + private val modelSettings = ModelSettings.getInstance() + + init { + isSmallVariant = true + updateTemplatePresentation(selectedProvider) + + ApplicationManager.getApplication().messageBus.connect().subscribe( + ModelChangeNotifier.getTopic(), + object : ModelChangeNotifierAdapter() { + override fun modelChanged( + featureType: FeatureType, + newModel: String, + serviceType: ServiceType + ) { + if (featureType == FeatureType.AGENT && agentSession.externalAgentId == null) { + updateTemplatePresentation(serviceType) + } + } + } + ) + } + + fun createCustomComponent(place: String): JComponent { + return createCustomComponent(templatePresentation, place) + } + + override fun createCustomComponent( + presentation: Presentation, + place: String + ): JComponent { + val button = createComboBoxButton(presentation) + button.foreground = EditorColorsManager.getInstance().globalScheme.defaultForeground + button.border = null + button.putClientProperty("JButton.backgroundColor", Color(0, 0, 0, 0)) + button.putClientProperty( + "proxyai.refreshPresentation", + Runnable { + updateTemplatePresentation(modelSettings.getServiceForFeature(FeatureType.AGENT)) + } + ) + return button + } + + override fun createActionPopup( + group: DefaultActionGroup, + context: DataContext, + disposeCallback: Runnable? + ): JBPopup { + return ModelListPopups.createPopup(group, context, disposeCallback) + } + + override fun createPopupActionGroup( + button: JComponent, + context: DataContext + ): DefaultActionGroup { + return buildPopupActionGroup((button as ComboBoxButton).presentation) + } + + override fun shouldShowDisabledActions(): Boolean = true + + private fun buildPopupActionGroup(presentation: Presentation): DefaultActionGroup { + return DefaultActionGroup().apply { + addSeparator("Cloud") + addProxyAIGroup() + addModelGroup(presentation, ANTHROPIC, "Anthropic", Icons.Anthropic) + addModelGroup(presentation, OPENAI, "OpenAI", Icons.OpenAI) + addModelGroup(presentation, CUSTOM_OPENAI, "Custom OpenAI", Icons.OpenAI) + addModelGroup(presentation, GOOGLE, "Google", Icons.Google) + addModelGroup(presentation, MISTRAL, "Mistral", Icons.Mistral) + addInceptionGroup(presentation) + addOfflineGroups(presentation) + addExternalAgentsSection() + } + } + + private fun DefaultActionGroup.addProxyAIGroup() { + if (PROXYAI !in availableProviders) { + return + } + + val group = DefaultActionGroup.createPopupGroup { "ProxyAI" } + group.templatePresentation.icon = Icons.DefaultSmall + group.addAll(proxyAIModelActions().toList()) + add(group) + } + + private fun proxyAIModelActions(): Array { + return availableModelsForProvider(PROXYAI) + .map(::createCodeGPTModelAction) + .toTypedArray() + } + + private fun DefaultActionGroup.addModelGroup( + presentation: Presentation, + provider: ServiceType, + label: String, + icon: Icon + ) { + if (provider !in availableProviders) { + return + } + + val group = DefaultActionGroup.createPopupGroup { label } + group.templatePresentation.icon = icon + availableModelsForProvider(provider).forEach { model -> + group.add( + createModelAction( + serviceType = provider, + label = model.displayName, + icon = icon, + comboBoxPresentation = presentation + ) { + modelSettings.setModel(FeatureType.AGENT, model.model, provider) + } + ) + } + add(group) + } + + private fun DefaultActionGroup.addInceptionGroup(presentation: Presentation) { + if (INCEPTION !in availableProviders) { + return + } + + val group = DefaultActionGroup.createPopupGroup { "Inception" } + group.templatePresentation.icon = Icons.Inception + group.add(createInceptionModelAction(presentation)) + add(group) + } + + private fun DefaultActionGroup.addOfflineGroups(presentation: Presentation) { + if (LLAMA_CPP !in availableProviders && OLLAMA !in availableProviders) { + return + } + + addSeparator("Offline") + if (LLAMA_CPP in availableProviders) { + add(createLlamaModelAction(presentation)) + } + if (OLLAMA in availableProviders) { + add(createOllamaGroup(presentation)) + } + } + + private fun createOllamaGroup(presentation: Presentation): DefaultActionGroup { + val group = DefaultActionGroup.createPopupGroup { "Ollama" } + group.templatePresentation.icon = Icons.Ollama + ApplicationManager.getApplication() + .getService(OllamaSettings::class.java) + .state + .availableModels + .forEach { model -> + group.add(createOllamaModelAction(model, presentation)) + } + return group + } + + private fun DefaultActionGroup.addExternalAgentsSection() { + val externalAgents = availableExternalAgents() + if (externalAgents.isEmpty() && !showConfigureModels) { + return + } + + addSeparator("Agents") + if (externalAgents.isEmpty()) { + if (showConfigureModels) { + add(createNoAgentRuntimesAction()) + } + } else { + externalAgents.forEach { preset -> + add(createAgentRuntimeAction(preset)) + } + } + + if (showConfigureModels) { + addSeparator() + add(createGoToSettingsAction()) + } + } + + private fun updateTemplatePresentation(selectedService: ServiceType) { + val externalAgentId = agentSession.externalAgentId + if (!externalAgentId.isNullOrBlank()) { + updateExternalAgentPresentation(externalAgentId) + return + } + + val templateState = templateState(selectedService) + templatePresentation.icon = templateState.icon + templatePresentation.text = templateState.text + } + + private fun templateState(selectedService: ServiceType): TemplateState { + val modelCode = selectedAgentModelCode() + return when (selectedService) { + PROXYAI -> proxyAITemplateState(modelCode) + OPENAI -> providerTemplateState(OPENAI, Icons.OpenAI, modelCode) + CUSTOM_OPENAI -> TemplateState( + Icons.OpenAI, + customOpenAIModelDisplayName(modelCode) + ) + ANTHROPIC -> providerTemplateState(ANTHROPIC, Icons.Anthropic, modelCode) + LLAMA_CPP -> providerTemplateState(LLAMA_CPP, Icons.Llama, modelCode) + OLLAMA -> providerTemplateState(OLLAMA, Icons.Ollama, modelCode) + GOOGLE -> providerTemplateState( + GOOGLE, + Icons.Google, + resolvedGoogleModelCode(modelCode) + ) + MISTRAL -> providerTemplateState(MISTRAL, Icons.Mistral, modelCode) + INCEPTION -> providerTemplateState(INCEPTION, Icons.Inception, modelCode) + } + } + + private fun proxyAITemplateState(modelCode: String?): TemplateState { + val proxyAIModel = availableModelsForProvider(PROXYAI) + .firstOrNull { it.model == modelCode } + return TemplateState( + icon = proxyAIModel?.icon ?: Icons.DefaultSmall, + text = proxyAIModel?.displayName ?: "Unknown" + ) + } + + private fun providerTemplateState( + serviceType: ServiceType, + icon: Icon, + modelCode: String? + ): TemplateState { + return TemplateState(icon, modelSettings.getModelDisplayName(serviceType, modelCode)) + } + + private fun customOpenAIModelDisplayName(modelCode: String?): String { + return availableModelsForProvider(CUSTOM_OPENAI) + .firstOrNull { it.model == modelCode } + ?.displayName + ?: modelSettings.getModelDisplayName(CUSTOM_OPENAI, modelCode) + } + + private fun selectedAgentModelCode(): String? { + return modelSettings.getStoredModelForFeature(FeatureType.AGENT) + } + + private fun updateExternalAgentPresentation(externalAgentId: String) { + val preset = ExternalAcpAgents.find(externalAgentId) + if (preset != null) { + templatePresentation.icon = externalAgentIcon(preset.id) + templatePresentation.text = preset.displayName + return + } + + templatePresentation.icon = Icons.DefaultSmall + templatePresentation.text = "ACP" + } + + private fun createAgentRuntimeAction(preset: ExternalAcpAgentPreset): AnAction { + return object : DumbAwareAction( + preset.displayName, + preset.description, + externalAgentIcon(preset.id) + ) { + override fun update(event: AnActionEvent) { + event.presentation.isEnabled = preset.id != agentSession.externalAgentId + } + + override fun actionPerformed(event: AnActionEvent) { + agentSession.externalAgentId = preset.id + agentSession.externalAgentSessionId = null + onAgentRuntimeChanged(preset.id) + updateExternalAgentPresentation(preset.id) + onModelChange(modelSettings.getServiceForFeature(FeatureType.AGENT)) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + } + + private fun resolvedGoogleModelCode(modelCode: String?): String { + if (!modelCode.isNullOrBlank()) { + return modelCode + } + + return availableModelsForProvider(GOOGLE) + .firstOrNull() + ?.model + .orEmpty() + } + + private fun llamaCppPresentationText(): String { + val huggingFaceModel = LlamaSettings.getCurrentState().huggingFaceModel + val llamaModel = LlamaModel.findByHuggingFaceModel(huggingFaceModel) + return "%s (%dB)".format(llamaModel.label, huggingFaceModel.parameterSize) + } + + private fun createModelAction( + serviceType: ServiceType, + label: String, + icon: Icon, + comboBoxPresentation: Presentation, + onModelChanged: (() -> Unit)? = null + ): AnAction { + return object : DumbAwareAction(label, "", icon) { + override fun update(event: AnActionEvent) { + val currentExternalAgent = agentSession.externalAgentId + event.presentation.isEnabled = when { + !currentExternalAgent.isNullOrBlank() -> true + else -> event.presentation.text != comboBoxPresentation.text + } + } + + override fun actionPerformed(event: AnActionEvent) { + clearExternalAgentSelection() + onModelChanged?.invoke() + handleModelChange(serviceType) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + } + + private fun handleModelChange(serviceType: ServiceType) { + updateTemplatePresentation(serviceType) + onModelChange(serviceType) + } + + private fun createCodeGPTModelAction(model: ModelSelection): AnAction { + val selected = isProxyAIModelSelected(model.model) && agentSession.externalAgentId == null + return CodeGPTModelsListPopupAction( + model.displayName, + model.model, + model.icon ?: Icons.DefaultSmall, + false, + selected, + Runnable { + clearExternalAgentSelection() + setAgentModel(PROXYAI, model.model) + handleModelChange(PROXYAI) + } + ) + } + + private fun createOllamaModelAction(model: String, presentation: Presentation): AnAction { + return createModelAction( + serviceType = OLLAMA, + label = model, + icon = Icons.Ollama, + comboBoxPresentation = presentation + ) { + ApplicationManager.getApplication() + .getService(OllamaSettings::class.java) + .state + .model = model + setAgentModel(OLLAMA, model) + } + } + + private fun createLlamaModelAction(presentation: Presentation): AnAction { + return createModelAction( + serviceType = LLAMA_CPP, + label = llamaCppPresentationText(), + icon = Icons.Llama, + comboBoxPresentation = presentation + ) { + setAgentModel( + LLAMA_CPP, + LlamaSettings.getCurrentState().huggingFaceModel.code + ) + } + } + + private fun createInceptionModelAction(presentation: Presentation): AnAction { + val modelCode = availableModelsForProvider(INCEPTION) + .firstOrNull() + ?.model + ?: "mercury" + return createModelAction( + serviceType = INCEPTION, + label = modelSettings.getModelDisplayName(INCEPTION, modelCode), + icon = Icons.Inception, + comboBoxPresentation = presentation + ) { + setAgentModel(INCEPTION, modelCode) + } + } + + private fun availableExternalAgents(): List { + return project.service().getVisiblePresets(agentSession.externalAgentId) + } + + private fun createNoAgentRuntimesAction(): DumbAwareAction { + return object : DumbAwareAction("No Agent Runtimes") { + override fun actionPerformed(event: AnActionEvent) = Unit + + override fun update(event: AnActionEvent) { + event.presentation.isEnabled = false + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + } + + private fun createGoToSettingsAction(): DumbAwareAction { + return object : DumbAwareAction("Go to Settings", "", AllIcons.General.Settings) { + override fun actionPerformed(event: AnActionEvent) { + ShowSettingsUtil.getInstance() + .showSettingsDialog(project, GeneralSettingsConfigurable::class.java) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + } + + private fun externalAgentIcon(externalAgentId: String): Icon { + return AcpIcons.iconFor(externalAgentId) + } + + private fun availableModelsForFeature(): List { + return modelSettings.getAvailableModels(FeatureType.AGENT) + } + + private fun availableModelsForProvider(provider: ServiceType): List { + return availableModelsForFeature().filter { it.provider == provider } + } + + private fun clearExternalAgentSelection() { + agentSession.externalAgentId = null + agentSession.externalAgentSessionId = null + onAgentRuntimeChanged(null) + } + + private fun setAgentModel(serviceType: ServiceType, modelCode: String) { + modelSettings.setModel(FeatureType.AGENT, modelCode, serviceType) + } + + private fun isProxyAIModelSelected(modelCode: String?): Boolean { + val current = modelSettings.getModelSelection(FeatureType.AGENT) ?: return false + return current.provider == PROXYAI && modelCode == current.model + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRuntimeOptionsComboBoxAction.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRuntimeOptionsComboBoxAction.kt new file mode 100644 index 00000000..6884ecd3 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRuntimeOptionsComboBoxAction.kt @@ -0,0 +1,205 @@ +package ee.carlrobert.codegpt.toolwindow.agent.ui + +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.ex.ComboBoxAction +import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.ui.AnimatedIcon +import ee.carlrobert.codegpt.toolwindow.agent.AcpConfigOption +import ee.carlrobert.codegpt.toolwindow.agent.AgentSession +import ee.carlrobert.codegpt.toolwindow.ui.ModelListPopups +import java.awt.Color +import javax.swing.Icon +import javax.swing.JComponent + +class AgentRuntimeOptionsComboBoxAction( + private val agentSession: AgentSession, + private val onAcpConfigChanged: (String, String) -> Unit +) : ComboBoxAction() { + + init { + isSmallVariant = true + refreshPresentation(templatePresentation) + } + + fun createCustomComponent(place: String): JComponent { + return createCustomComponent(templatePresentation, place) + } + + override fun createCustomComponent( + presentation: Presentation, + place: String + ): JComponent { + val button = createComboBoxButton(presentation) + val livePresentation = button.presentation + refreshPresentation(livePresentation) + button.isEnabled = hasExternalAgentSelected() + button.foreground = EditorColorsManager.getInstance().globalScheme.defaultForeground + button.border = null + button.putClientProperty("JButton.backgroundColor", Color(0, 0, 0, 0)) + button.putClientProperty( + "proxyai.refreshPresentation", + Runnable { + refreshPresentation(livePresentation) + button.isEnabled = hasExternalAgentSelected() + } + ) + return button + } + + override fun createActionPopup( + group: DefaultActionGroup, + context: DataContext, + disposeCallback: Runnable? + ): JBPopup { + return ModelListPopups.createPopup(group, context, disposeCallback) + } + + override fun createPopupActionGroup( + button: JComponent, + context: DataContext + ): DefaultActionGroup { + val actionGroup = DefaultActionGroup() + val errorMessage = agentSession.externalAgentErrorMessage + + when { + !errorMessage.isNullOrBlank() -> { + actionGroup.add(createDisabledInfoAction(errorMessage)) + } + + agentSession.externalAgentConfigLoading -> { + actionGroup.add(createDisabledInfoAction("Loading...", AnimatedIcon.Default())) + } + + selectableOptions.isEmpty() -> { + actionGroup.add(createDisabledInfoAction("No options available")) + } + + else -> { + selectableOptions.forEach { option -> + actionGroup.add(createOptionGroup(option)) + } + } + } + + return actionGroup + } + + override fun shouldShowDisabledActions(): Boolean = true + + private fun createOptionGroup(option: AcpConfigOption): DefaultActionGroup { + val group = DefaultActionGroup.createPopupGroup { optionLabel(option) } + option.options.forEach { choice -> + val selected = choice.value == option.currentValue + group.add( + object : DumbAwareAction( + choice.name, + choice.description, + if (selected) AllIcons.Actions.Checked else null + ) { + override fun update(event: AnActionEvent) { + event.presentation.isEnabled = + !selected && !agentSession.externalAgentConfigLoading + } + + override fun actionPerformed(event: AnActionEvent) { + onAcpConfigChanged(option.id, choice.value) + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + ) + } + return group + } + + private fun refreshPresentation(presentation: Presentation) { + presentation.icon = if (agentSession.externalAgentConfigLoading) { + AnimatedIcon.Default() + } else { + null + } + presentation.text = buildSummaryText() + } + + private fun hasExternalAgentSelected(): Boolean { + return !agentSession.externalAgentId.isNullOrBlank() + } + + private fun buildSummaryText(): String { + if (agentSession.externalAgentConfigLoading) { + return "Loading..." + } + + agentSession.externalAgentErrorMessage + ?.takeIf(String::isNotBlank) + ?.let { return it } + + val parts = listOfNotNull( + selectedOptionValue("model"), + selectedOptionValue("thought_level") + ).ifEmpty { + listOfNotNull(selectedOptionValue("mode")) + } + + return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") ?: "Options" + } + + private fun selectedOptionValue(category: String): String? { + val option = selectableOptions.firstOrNull { it.category == category } ?: return null + return option.options + .firstOrNull { it.value == option.currentValue } + ?.name + ?: option.currentValue + } + + private val selectableOptions: List + get() = agentSession.externalAgentConfigOptions + .asSequence() + .filter { it.type == "select" && it.options.isNotEmpty() } + .sortedBy { categoryOrder(it.category) } + .toList() + + private fun optionLabel(option: AcpConfigOption): String { + return when (option.category.orEmpty()) { + "model" -> "Model" + "mode" -> "Mode" + "thought_level" -> "Reasoning" + else -> option.name + } + } + + private fun createDisabledInfoAction( + text: String, + icon: Icon? = null + ): DumbAwareAction { + return object : DumbAwareAction(text, "", icon) { + override fun actionPerformed(event: AnActionEvent) = Unit + + override fun update(event: AnActionEvent) { + event.presentation.isEnabled = false + } + + override fun getActionUpdateThread(): ActionUpdateThread { + return ActionUpdateThread.BGT + } + } + } + + private fun categoryOrder(category: String?): Int { + return when (category.orEmpty()) { + "model" -> 0 + "mode" -> 1 + "thought_level" -> 2 + else -> 3 + } + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentToolWindowLandingPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentToolWindowLandingPanel.kt index b875f742..9b50ffab 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentToolWindowLandingPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentToolWindowLandingPanel.kt @@ -40,6 +40,7 @@ import ee.carlrobert.codegpt.toolwindow.agent.history.AgentHistoryListPanel import ee.carlrobert.codegpt.toolwindow.ui.ResponseMessagePanel import ee.carlrobert.codegpt.ui.UIUtil import ee.carlrobert.codegpt.ui.UIUtil.createTextPane +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import ee.carlrobert.codegpt.util.coroutines.DisposableCoroutineScope import com.intellij.openapi.Disposable @@ -336,16 +337,19 @@ class AgentToolWindowLandingPanel(private val project: Project) : BorderLayoutPa if (disposed || project.isDisposed) return val shouldRefresh = offset == 0 && refreshHistory backgroundScope.launch { - val page = runCatching { + val page = try { historyService.listThreadsPage( query = query, offset = offset, limit = limit, refresh = shouldRefresh ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + logger.warn("Failed to load checkpoint history", throwable) + null } - .onFailure { logger.warn("Failed to load checkpoint history", it) } - .getOrNull() if (disposed || project.isDisposed) return@launch runInEdt { @@ -361,20 +365,26 @@ class AgentToolWindowLandingPanel(private val project: Project) : BorderLayoutPa private fun openCheckpointThread(thread: AgentHistoryThreadSummary) { if (disposed || project.isDisposed) return backgroundScope.launch { - val checkpoint = runCatching { + val checkpoint = try { historyService.loadCheckpoint(thread.latest) - }.onFailure { - logger.warn("Failed to open checkpoint thread ${thread.agentId}", it) - }.getOrNull() ?: return@launch + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + logger.warn("Failed to open checkpoint thread ${thread.agentId}", throwable) + null + } ?: return@launch - val conversation = runCatching { + val conversation = try { AgentCheckpointConversationMapper.toConversation( checkpoint = checkpoint, projectInstructions = loadProjectInstructions(project.basePath) ) - }.onFailure { - logger.warn("Failed to open checkpoint thread ${thread.agentId}", it) - }.getOrNull() ?: return@launch + } catch (cancelled: CancellationException) { + throw cancelled + } catch (throwable: Throwable) { + logger.warn("Failed to open checkpoint thread ${thread.agentId}", throwable) + null + } ?: return@launch if (disposed || project.isDisposed) return@launch runInEdt { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt index 30ed0174..bc8e6982 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt @@ -93,8 +93,16 @@ object ToolCallDescriptorFactory { val mcpArgs = args as? McpTool.Args val mcpResult = result as? McpTool.Result val resolvedToolName = mcpResult?.toolName ?: mcpArgs?.toolName ?: toolName - val server = mcpResult?.serverName ?: mcpResult?.serverId ?: mcpArgs?.serverName ?: mcpArgs?.serverId + val server = + mcpResult?.serverName ?: mcpResult?.serverId ?: mcpArgs?.serverName ?: mcpArgs?.serverId val titleMain = resolvedToolName + val summary = mcpArgs?.arguments + ?.entries + ?.take(2) + ?.joinToString(" · ") { (key, value) -> + val valueText = value.toString().trim('"') + "$key=${truncateQuery(valueText)}" + } val actions = if (mcpResult != null) { listOf( @@ -117,7 +125,8 @@ object ToolCallDescriptorFactory { result = result, projectId = projectId, secondaryBadges = listOfNotNull(serverBadge), - actions = actions + actions = actions, + summary = summary ) } @@ -133,6 +142,7 @@ object ToolCallDescriptorFactory { showTextDialog(result.loadedContent, "Skill Content: ${result.name}") } ) + else -> emptyList() } return ToolCallDescriptor( @@ -474,7 +484,8 @@ object ToolCallDescriptorFactory { private fun buildSearchBadges(result: Any?): List { return if (result is IntelliJSearchTool.Result) { - listOf(Badge( + listOf( + Badge( "[${result.totalMatches} matches]", JBColor.BLUE, action = { showTextDialog(result.output, "Search Results") } @@ -769,10 +780,10 @@ object ToolCallDescriptorFactory { ): ToolCallDescriptor { return ToolCallDescriptor( kind = ToolKind.OTHER, - icon = AllIcons.Actions.Help, - titlePrefix = "Tool:", + icon = AllIcons.Actions.Execute, + titlePrefix = "", titleMain = toolName, - tooltip = "Tool: $toolName", + tooltip = toolName, args = args, result = result, projectId = projectId @@ -817,7 +828,8 @@ object ToolCallDescriptorFactory { private fun buildDocsBadges(result: Any?): List { return if (result is GetLibraryDocsTool.Result.Success) { - listOf(Badge( + listOf( + Badge( "[View Results]", JBColor.BLUE, action = { @@ -836,7 +848,8 @@ object ToolCallDescriptorFactory { return when (result) { is WebSearchTool.Result -> { val argsObj = args as? WebSearchTool.Args - val badges = mutableListOf(Badge( + val badges = mutableListOf( + Badge( "[${result.results.size} results]", JBColor.BLUE, action = { showWebResultsDialog(result) } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/ModelListPopups.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/ModelListPopups.kt new file mode 100644 index 00000000..16ccc519 --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/ModelListPopups.kt @@ -0,0 +1,27 @@ +package ee.carlrobert.codegpt.toolwindow.ui + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent + +object ModelListPopups { + + fun createPopup( + group: DefaultActionGroup, + context: DataContext, + disposeCallback: Runnable? + ): JBPopup { + val popup = ModelListPopup(group, context) + if (disposeCallback != null) { + popup.addListener(object : JBPopupListener { + override fun onClosed(event: LightweightWindowEvent) { + disposeCallback.run() + } + }) + } + popup.isShowSubmenuOnHover = true + return popup + } +} diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt index c8bedbd9..e6ef95e1 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/hover/PsiLinkHoverPreview.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.project.Project import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.psi.PsiFile import com.intellij.psi.PsiElement import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.awt.RelativePoint @@ -18,6 +19,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import ee.carlrobert.codegpt.util.NavigationResolverFactory +import ee.carlrobert.codegpt.util.EditorUtil import java.awt.Dimension import java.awt.Image import java.awt.MouseInfo @@ -42,6 +44,8 @@ object PsiLinkHoverPreview { private const val FILE_PREFIX = "file://" private const val HOVER_DELAY_MS = 250L private const val EXIT_CLOSE_DELAY_MS = 120L + private const val FILE_PREVIEW_MAX_LINES = 120 + private const val FILE_PREVIEW_MAX_CHARS = 6000 @JvmStatic fun install(project: Project, textPane: JTextPane) { @@ -202,16 +206,12 @@ object PsiLinkHoverPreview { val maxW = JBUI.scale(600) val maxH = JBUI.scale(400) - editor.size = Dimension(maxW, Int.MAX_VALUE) - val pref = editor.preferredSize - val w = min(pref.width.coerceAtLeast(200), maxW) - val h = min(pref.height.coerceAtLeast(50), maxH) - scroll.preferredSize = Dimension(w, h) + scroll.preferredSize = computeHoverSize(editor, maxW, maxH) val newPopup = PopupFactoryImpl.getInstance() .createComponentPopupBuilder(scroll, null) .setRequestFocus(false) - .setResizable(true) + .setResizable(false) .setMovable(false) .setShowShadow(true) .setCancelOnClickOutside(true) @@ -262,6 +262,9 @@ object PsiLinkHoverPreview { text.replace("&", "&").replace("<", "<").replace(">", ">") private fun buildPsiHtmlOffEdt(element: Any?): String? { + if (element is PsiFile) { + return buildFilePreviewHtml(element) + } if (element !is PsiElement) return null val provider = DocumentationManager.getProviderFromElement(element) @@ -272,5 +275,49 @@ object PsiLinkHoverPreview { null } } + + private fun buildFilePreviewHtml(file: PsiFile): String? { + val virtualFile = file.virtualFile ?: return null + val rawContent = EditorUtil.getFileContent(virtualFile) + val excerpt = rawContent.lineSequence() + .take(FILE_PREVIEW_MAX_LINES) + .joinToString("\n") + .take(FILE_PREVIEW_MAX_CHARS) + val truncated = excerpt.length < rawContent.length || + rawContent.lineSequence().drop(FILE_PREVIEW_MAX_LINES).any() + val renderedContent = buildString { + append(escape(excerpt.ifBlank { "" })) + if (truncated) { + append("\n...") + } + } + return """ + + +
${escape(virtualFile.name)}
+
${escape(virtualFile.path)}
+
$renderedContent
+ + + """.trimIndent() + } + + private fun computeHoverSize( + editor: DocumentationHintEditorPane, + maxW: Int, + maxH: Int + ): Dimension { + return runCatching { + editor.size = Dimension(maxW, maxH) + val preferredSize = editor.preferredSize + Dimension( + min(preferredSize.width.coerceAtLeast(200), maxW), + min(preferredSize.height.coerceAtLeast(50), maxH) + ) + }.getOrElse { error -> + logger.warn("Failed to measure hover preview; using fallback size", error) + Dimension(JBUI.scale(360), JBUI.scale(220)) + } + } } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt index 437ee1b6..0991f0a8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/ui/textarea/UserInputPanel.kt @@ -66,44 +66,23 @@ class UserInputPanel @JvmOverloads constructor( val tagManager: TagManager, private val onSubmit: (String) -> Unit, private val onStop: () -> Unit, + withRemovableSelectedEditorTag: Boolean = true, private val onAcceptAll: (() -> Unit)? = null, private val onRejectAll: (() -> Unit)? = null, onApply: (() -> Unit)? = null, getMarkdownContent: (() -> String)? = null, - withRemovableSelectedEditorTag: Boolean = true, private val agentTokenCounterPanel: JComponent? = null, + private val agentTokenCounterVisibilityProvider: (() -> Boolean)? = null, private val sessionIdProvider: (() -> String?)? = null, private val conversationIdProvider: (() -> UUID?)? = null, private val onStartSessionTimeline: (() -> Unit)? = null, + private val modelSelectorComponentFactory: ((UserInputPanel) -> JComponent)? = null, + private val secondaryFooterComponentFactory: ((UserInputPanel) -> JComponent)? = null, + private val secondaryFooterComponentVisibilityProvider: (() -> Boolean)? = null, + private val promptEnhancerVisibilityProvider: (() -> Boolean)? = null, + private val sessionTimelineVisibilityProvider: (() -> Boolean)? = null, ) : BorderLayoutPanel() { - constructor( - project: Project, - totalTokensPanel: TotalTokensPanel, - parentDisposable: Disposable, - featureType: FeatureType, - tagManager: TagManager, - onSubmit: (String) -> Unit, - onStop: () -> Unit, - withRemovableSelectedEditorTag: Boolean - ) : this( - project, - totalTokensPanel, - parentDisposable, - featureType, - tagManager, - onSubmit, - onStop, - null, - null, - null, - null, - withRemovableSelectedEditorTag, - null, - null, - null - ) - companion object { private const val CORNER_RADIUS = 16 } @@ -145,6 +124,9 @@ class UserInputPanel @JvmOverloads constructor( ) private var footerPanelRef: JPanel? = null + private var modelSelectorComponentRef: JComponent? = null + private var secondaryFooterComponentRef: JComponent? = null + private var secondaryFooterSeparatorRef: JComponent? = null private val tokenUsageCounterPanel = agentTokenCounterPanel as? TokenUsageCounterPanel private val applyChip = @@ -592,17 +574,39 @@ class UserInputPanel @JvmOverloads constructor( private fun createFooterPanel(featureType: FeatureType): JPanel { val modelSettings = ModelSettings.getInstance() - val currentService = modelSettings.getServiceForFeature(featureType) - val availableProviders = modelSettings.getAvailableProviders(featureType) - val modelComboBox = ModelComboBoxAction( - { imageActionSupported.set(isImageActionSupported()) }, - currentService, - availableProviders, - true, - featureType - ).createCustomComponent(ActionPlaces.UNKNOWN).apply { + val modelComboBox = modelSelectorComponentFactory?.invoke(this) ?: run { + val currentService = modelSettings.getServiceForFeature(featureType) + val availableProviders = modelSettings.getAvailableProviders(featureType) + ModelComboBoxAction( + { imageActionSupported.set(isImageActionSupported()) }, + currentService, + availableProviders, + true, + featureType + ).createCustomComponent(ActionPlaces.UNKNOWN) + }.apply { cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) } + modelSelectorComponentRef = modelComboBox + val secondaryFooterComponent = secondaryFooterComponentFactory?.invoke(this)?.apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + val secondaryFooterSeparator = if (secondaryFooterComponent != null) { + createActionSeparator() + } else { + null + } + secondaryFooterComponentRef = secondaryFooterComponent + secondaryFooterSeparatorRef = secondaryFooterSeparator + val isSecondaryFooterVisible = secondaryFooterComponentVisibilityProvider?.invoke() ?: true + secondaryFooterComponent?.isVisible = isSecondaryFooterVisible + secondaryFooterSeparator?.isVisible = isSecondaryFooterVisible + agentTokenCounterPanel?.isVisible = agentTokenCounterVisibilityProvider?.invoke() ?: true + promptEnhancerButton?.isVisible = promptEnhancerVisibilityProvider?.invoke() ?: true + val isSessionTimelineVisible = sessionTimelineVisibilityProvider?.invoke() + ?: (onStartSessionTimeline != null) + sessionTimelineButton?.isVisible = isSessionTimelineVisible + sessionTimelineSeparator?.isVisible = isSessionTimelineVisible val pnl = panel { twoColumnsRow( @@ -610,6 +614,12 @@ class UserInputPanel @JvmOverloads constructor( panel { row { cell(modelComboBox).gap(RightGap.SMALL) + if (secondaryFooterComponent != null) { + if (secondaryFooterSeparator != null) { + cell(secondaryFooterSeparator).gap(RightGap.SMALL) + } + cell(secondaryFooterComponent).gap(RightGap.SMALL) + } if (agentTokenCounterPanel != null) { cell(agentTokenCounterPanel).gap(RightGap.SMALL) } @@ -683,6 +693,24 @@ class UserInputPanel @JvmOverloads constructor( return model?.llmModel?.capabilities?.any { it is LLMCapability.Vision.Image } == true } + fun refreshModelDependentState() { + imageActionSupported.set(isImageActionSupported()) + agentTokenCounterPanel?.isVisible = agentTokenCounterVisibilityProvider?.invoke() ?: true + val isSecondaryFooterVisible = secondaryFooterComponentVisibilityProvider?.invoke() ?: true + secondaryFooterComponentRef?.isVisible = isSecondaryFooterVisible + secondaryFooterSeparatorRef?.isVisible = isSecondaryFooterVisible + promptEnhancerButton?.isVisible = promptEnhancerVisibilityProvider?.invoke() ?: true + val isSessionTimelineVisible = sessionTimelineVisibilityProvider?.invoke() + ?: (onStartSessionTimeline != null) + sessionTimelineButton?.isVisible = isSessionTimelineVisible + sessionTimelineSeparator?.isVisible = isSessionTimelineVisible + (modelSelectorComponentRef?.getClientProperty("proxyai.refreshPresentation") as? Runnable)?.run() + (secondaryFooterComponentRef?.getClientProperty("proxyai.refreshPresentation") as? Runnable)?.run() + footerPanelRef?.revalidate() + footerPanelRef?.repaint() + updatePreferredSizeFromChildren() + } + private fun updatePreferredSizeFromChildren() { val headerHeight = userInputHeaderPanel.preferredSize?.height ?: 0 val textFieldHeight = promptTextField.preferredSize?.height ?: 0 diff --git a/src/main/kotlin/ee/carlrobert/codegpt/util/CommandRuntimeHelper.kt b/src/main/kotlin/ee/carlrobert/codegpt/util/CommandRuntimeHelper.kt new file mode 100644 index 00000000..cfdbe97c --- /dev/null +++ b/src/main/kotlin/ee/carlrobert/codegpt/util/CommandRuntimeHelper.kt @@ -0,0 +1,67 @@ +package ee.carlrobert.codegpt.util + +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.util.EnvironmentUtil +import java.io.File + +object CommandRuntimeHelper { + + fun resolveCommand( + command: String, + extraEnvironment: Map = emptyMap() + ): String? { + val commandFile = File(command) + if (commandFile.isAbsolute && commandFile.exists() && commandFile.canExecute()) { + return commandFile.absolutePath + } + + val environment = createEnvironment( + extraEnvironment = extraEnvironment, + resolvedCommand = null, + includeResolvedCommandParent = false + ) + val pathValue = environment["PATH"] + + return if (pathValue.isNullOrBlank()) { + PathEnvironmentVariableUtil.findInPath(command)?.absolutePath + } else { + PathEnvironmentVariableUtil.findInPath(command, pathValue, null)?.absolutePath + ?: PathEnvironmentVariableUtil.findInPath(command)?.absolutePath + } + } + + fun createEnvironment( + extraEnvironment: Map, + resolvedCommand: String? = null, + includeResolvedCommandParent: Boolean = true + ): MutableMap { + val parentEnvironment = EnvironmentUtil.getEnvironmentMap().ifEmpty { System.getenv() } + val environment = parentEnvironment.toMutableMap() + environment.putAll(extraEnvironment) + EnvironmentUtil.inlineParentOccurrences(environment, parentEnvironment) + + if (includeResolvedCommandParent) { + resolvedCommand + ?.let(::File) + ?.parentFile + ?.takeIf { it.isDirectory } + ?.absolutePath + ?.let { appendPathEntry(environment, it) } + } + + return environment + } + + private fun appendPathEntry( + environment: MutableMap, + pathEntry: String + ) { + val pathEntries = linkedSetOf() + val currentPath = environment["PATH"] + if (!currentPath.isNullOrBlank()) { + PathEnvironmentVariableUtil.getPathDirs(currentPath).forEach(pathEntries::add) + } + pathEntries.add(pathEntry) + environment["PATH"] = pathEntries.joinToString(File.pathSeparator) + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 12d53b68..9989fbb1 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,313 +1,366 @@ - ee.carlrobert.chatgpt - Proxy AI - Carl-Robert Linnupuu - com.intellij.modules.platform - com.intellij.modules.lang - org.jetbrains.kotlin - com.intellij.modules.java - JavaScript - com.intellij.modules.cpp - - - - - Git4Idea + ee.carlrobert.chatgpt + Proxy AI + Carl-Robert Linnupuu + com.intellij.modules.platform + com.intellij.modules.lang + org.jetbrains.kotlin + com.intellij.modules.java + JavaScript + com.intellij.modules.cpp + + + + + Git4Idea - - - - - + + + + + - - - - - - + + + + + + - - - - + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - messages.codegpt + messages.codegpt - + + + + + + + + + + + + + + + - + id="CodeGPT.NewChat" + class="ee.carlrobert.codegpt.actions.editor.OpenNewChatAction" + text="New Chat" + description="Creates a new chat session"> + - - + id="CodeGPT.ContextMenuInlineEditAction" + text="Inline Edit" + description="Edit code inline from editor's context menu" + class="ee.carlrobert.codegpt.actions.editor.InlineEditContextMenuAction"> + - - + id="CodeGPT.AskQuestion" + text="Ask Question" + class="ee.carlrobert.codegpt.actions.editor.AskQuestionAction"> + - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + - + id="CodeGPT.IncludeFilesInContextAction" + text="Include Files In Prompt" + class="ee.carlrobert.codegpt.actions.IncludeFilesInContextAction"/> + + + + - - - + + + + + + + - - - - - + + - - - - + + + - - - - + + + + + - - - - + + + + - - - - + + + + - - - - - - + + + + - - - - - + + + + - - - - - + + + + + + - - - - - + + + + + - - - + + + + + - - - - - - - - + + + + + - + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/agentpool.png b/src/main/resources/icons/agents/agentpool.png new file mode 100644 index 00000000..7ffe45d5 Binary files /dev/null and b/src/main/resources/icons/agents/agentpool.png differ diff --git a/src/main/resources/icons/agents/auggie.png b/src/main/resources/icons/agents/auggie.png new file mode 100644 index 00000000..da8d4f13 Binary files /dev/null and b/src/main/resources/icons/agents/auggie.png differ diff --git a/src/main/resources/icons/agents/blackbox.svg b/src/main/resources/icons/agents/blackbox.svg new file mode 100644 index 00000000..edc2a315 --- /dev/null +++ b/src/main/resources/icons/agents/blackbox.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/icons/agents/claude-agent.svg b/src/main/resources/icons/agents/claude-agent.svg new file mode 100644 index 00000000..98dd82db --- /dev/null +++ b/src/main/resources/icons/agents/claude-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/claude.svg b/src/main/resources/icons/agents/claude.svg new file mode 100644 index 00000000..cefd926a --- /dev/null +++ b/src/main/resources/icons/agents/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/cline.svg b/src/main/resources/icons/agents/cline.svg new file mode 100644 index 00000000..aeeafbc6 --- /dev/null +++ b/src/main/resources/icons/agents/cline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/agents/code-assistant.svg b/src/main/resources/icons/agents/code-assistant.svg new file mode 100644 index 00000000..757c5a1c --- /dev/null +++ b/src/main/resources/icons/agents/code-assistant.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/icons/agents/codex.svg b/src/main/resources/icons/agents/codex.svg new file mode 100644 index 00000000..4bb30281 --- /dev/null +++ b/src/main/resources/icons/agents/codex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/agents/cursor.svg b/src/main/resources/icons/agents/cursor.svg new file mode 100644 index 00000000..dc198d08 --- /dev/null +++ b/src/main/resources/icons/agents/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/cursor_dark.svg b/src/main/resources/icons/agents/cursor_dark.svg new file mode 100644 index 00000000..ba6b4ab7 --- /dev/null +++ b/src/main/resources/icons/agents/cursor_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/docker-cagent.svg b/src/main/resources/icons/agents/docker-cagent.svg new file mode 100644 index 00000000..cf36d6c5 --- /dev/null +++ b/src/main/resources/icons/agents/docker-cagent.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/factory-droid.svg b/src/main/resources/icons/agents/factory-droid.svg new file mode 100644 index 00000000..5c6fb8d1 --- /dev/null +++ b/src/main/resources/icons/agents/factory-droid.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/icons/agents/fast-agent.svg b/src/main/resources/icons/agents/fast-agent.svg new file mode 100644 index 00000000..a07fab28 --- /dev/null +++ b/src/main/resources/icons/agents/fast-agent.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/gemini-agent.svg b/src/main/resources/icons/agents/gemini-agent.svg new file mode 100644 index 00000000..2c9d603a --- /dev/null +++ b/src/main/resources/icons/agents/gemini-agent.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/github-copilot.svg b/src/main/resources/icons/agents/github-copilot.svg new file mode 100644 index 00000000..a9c822d7 --- /dev/null +++ b/src/main/resources/icons/agents/github-copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/goose.svg b/src/main/resources/icons/agents/goose.svg new file mode 100644 index 00000000..0cff87f4 --- /dev/null +++ b/src/main/resources/icons/agents/goose.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/junie.svg b/src/main/resources/icons/agents/junie.svg new file mode 100644 index 00000000..63b60e8f --- /dev/null +++ b/src/main/resources/icons/agents/junie.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/main/resources/icons/agents/kimi.svg b/src/main/resources/icons/agents/kimi.svg new file mode 100644 index 00000000..4f7547cf --- /dev/null +++ b/src/main/resources/icons/agents/kimi.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/kiro.svg b/src/main/resources/icons/agents/kiro.svg new file mode 100644 index 00000000..5fe3cf68 --- /dev/null +++ b/src/main/resources/icons/agents/kiro.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/minion-code.svg b/src/main/resources/icons/agents/minion-code.svg new file mode 100644 index 00000000..eb3d8eb3 --- /dev/null +++ b/src/main/resources/icons/agents/minion-code.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/mistral-vibe.svg b/src/main/resources/icons/agents/mistral-vibe.svg new file mode 100644 index 00000000..b13631b9 --- /dev/null +++ b/src/main/resources/icons/agents/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/openclaw.svg b/src/main/resources/icons/agents/openclaw.svg new file mode 100644 index 00000000..bcbc1e10 --- /dev/null +++ b/src/main/resources/icons/agents/openclaw.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/opencode.svg b/src/main/resources/icons/agents/opencode.svg new file mode 100644 index 00000000..af52624b --- /dev/null +++ b/src/main/resources/icons/agents/opencode.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/opencode_dark.svg b/src/main/resources/icons/agents/opencode_dark.svg new file mode 100644 index 00000000..c5db8883 --- /dev/null +++ b/src/main/resources/icons/agents/opencode_dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/agents/openhands.svg b/src/main/resources/icons/agents/openhands.svg new file mode 100644 index 00000000..3aa40d44 --- /dev/null +++ b/src/main/resources/icons/agents/openhands.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/icons/agents/pi-acp.svg b/src/main/resources/icons/agents/pi-acp.svg new file mode 100644 index 00000000..68ea8fd7 --- /dev/null +++ b/src/main/resources/icons/agents/pi-acp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/agents/qoder.svg b/src/main/resources/icons/agents/qoder.svg new file mode 100644 index 00000000..417d8369 --- /dev/null +++ b/src/main/resources/icons/agents/qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/qwen.png b/src/main/resources/icons/agents/qwen.png similarity index 100% rename from src/main/resources/icons/qwen.png rename to src/main/resources/icons/agents/qwen.png diff --git a/src/main/resources/icons/agents/stakpak.svg b/src/main/resources/icons/agents/stakpak.svg new file mode 100644 index 00000000..64425076 --- /dev/null +++ b/src/main/resources/icons/agents/stakpak.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/icons/agents/vtcode.svg b/src/main/resources/icons/agents/vtcode.svg new file mode 100644 index 00000000..47bb210e --- /dev/null +++ b/src/main/resources/icons/agents/vtcode.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/test/kotlin/ee/carlrobert/codegpt/agent/AgentServiceIntegrationTest.kt b/src/test/kotlin/ee/carlrobert/codegpt/agent/AgentServiceIntegrationTest.kt index b73cda95..5ef8d2c9 100644 --- a/src/test/kotlin/ee/carlrobert/codegpt/agent/AgentServiceIntegrationTest.kt +++ b/src/test/kotlin/ee/carlrobert/codegpt/agent/AgentServiceIntegrationTest.kt @@ -191,7 +191,18 @@ class AgentServiceIntegrationTest : IntegrationTest() { contentManager.createNewAgentTab( AgentSession( sessionId = sessionId, - conversation = Conversation() + conversation = Conversation(), + displayName = "", + serviceType = null, + externalAgentId = null, + externalAgentSessionId = null, + externalAgentConfigOptions = emptyList(), + externalAgentConfigLoading = false, + runtimeAgentId = null, + resumeCheckpointRef = null, + referencedFiles = emptyList(), + createdAt = System.currentTimeMillis(), + lastActiveAt = System.currentTimeMillis() ), select = false )