From 9a79bd1610f18d56498968f5fecf06c42eb87b3c Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Mon, 16 Mar 2026 14:52:37 +0000 Subject: [PATCH] feat: introduce agent client protocol (acp) --- .../java/ee/carlrobert/codegpt/Icons.java | 1 - .../carlrobert/codegpt/agent/AgentService.kt | 58 +- .../codegpt/agent/external/AcpAgentCatalog.kt | 264 +++++++ .../codegpt/agent/external/AcpAgentService.kt | 739 ++++++++++++++++++ .../codegpt/agent/external/AcpIcons.kt | 47 ++ .../codegpt/agent/external/AcpJson.kt | 88 +++ .../agent/external/AcpJsonRpcConnection.kt | 257 ++++++ .../agent/external/AcpProcessHelper.kt | 41 + .../agent/external/AcpSessionConfigAdapter.kt | 266 +++++++ .../agent/external/AcpSessionUpdateParser.kt | 119 +++ .../agent/external/AcpToolCallDecoder.kt | 336 ++++++++ .../history/AgentCheckpointHistoryService.kt | 12 +- .../codegpt/mcp/McpCommandValidator.kt | 117 +-- .../carlrobert/codegpt/mcp/McpPathHelper.kt | 100 +-- .../codegpt/mcp/McpSessionManager.kt | 7 +- .../agents/acp/AcpAgentConfigurable.kt | 27 + .../settings/agents/acp/AcpAgentSettings.kt | 34 + .../agents/acp/AcpAgentSettingsForm.kt | 238 ++++++ .../codegpt/settings/mcp/McpClientManager.kt | 13 +- .../models/SettingsModelComboBoxAction.kt | 14 +- .../codegpt/toolwindow/agent/AgentSession.kt | 24 +- .../toolwindow/agent/AgentToolWindowPanel.kt | 4 +- .../agent/AgentToolWindowTabPanel.kt | 114 ++- .../agent/AgentToolWindowTabbedPane.kt | 10 +- .../agent/ui/AgentModelComboBoxAction.kt | 509 ++++++++++++ .../ui/AgentRuntimeOptionsComboBoxAction.kt | 205 +++++ .../agent/ui/AgentToolWindowLandingPanel.kt | 32 +- .../descriptor/ToolCallDescriptorFactory.kt | 29 +- .../codegpt/toolwindow/ui/ModelListPopups.kt | 27 + .../codegpt/ui/hover/PsiLinkHoverPreview.kt | 59 +- .../codegpt/ui/textarea/UserInputPanel.kt | 102 ++- .../codegpt/util/CommandRuntimeHelper.kt | 67 ++ src/main/resources/META-INF/plugin.xml | 615 ++++++++------- src/main/resources/icons/agents/agentpool.png | Bin 0 -> 98365 bytes src/main/resources/icons/agents/auggie.png | Bin 0 -> 3308 bytes src/main/resources/icons/agents/blackbox.svg | 5 + .../resources/icons/agents/claude-agent.svg | 3 + src/main/resources/icons/agents/claude.svg | 3 + src/main/resources/icons/agents/cline.svg | 6 + .../resources/icons/agents/code-assistant.svg | 1 + src/main/resources/icons/agents/codex.svg | 10 + src/main/resources/icons/agents/cursor.svg | 3 + .../resources/icons/agents/cursor_dark.svg | 3 + .../resources/icons/agents/docker-cagent.svg | 14 + .../resources/icons/agents/factory-droid.svg | 1 + .../resources/icons/agents/fast-agent.svg | 293 +++++++ .../resources/icons/agents/gemini-agent.svg | 20 + .../resources/icons/agents/github-copilot.svg | 3 + src/main/resources/icons/agents/goose.svg | 3 + src/main/resources/icons/agents/junie.svg | 5 + src/main/resources/icons/agents/kimi.svg | 3 + src/main/resources/icons/agents/kiro.svg | 11 + .../resources/icons/agents/minion-code.svg | 19 + .../resources/icons/agents/mistral-vibe.svg | 12 + src/main/resources/icons/agents/openclaw.svg | 22 + src/main/resources/icons/agents/opencode.svg | 16 + .../resources/icons/agents/opencode_dark.svg | 16 + src/main/resources/icons/agents/openhands.svg | 9 + src/main/resources/icons/agents/pi-acp.svg | 4 + src/main/resources/icons/agents/qoder.svg | 3 + .../resources/icons/{ => agents}/qwen.png | Bin src/main/resources/icons/agents/stakpak.svg | 3 + src/main/resources/icons/agents/vtcode.svg | 4 + .../agent/AgentServiceIntegrationTest.kt | 13 +- 64 files changed, 4510 insertions(+), 573 deletions(-) create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentCatalog.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpIcons.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJson.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpJsonRpcConnection.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpProcessHelper.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionConfigAdapter.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentConfigurable.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettings.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/settings/agents/acp/AcpAgentSettingsForm.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentModelComboBoxAction.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/AgentRuntimeOptionsComboBoxAction.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/toolwindow/ui/ModelListPopups.kt create mode 100644 src/main/kotlin/ee/carlrobert/codegpt/util/CommandRuntimeHelper.kt create mode 100644 src/main/resources/icons/agents/agentpool.png create mode 100644 src/main/resources/icons/agents/auggie.png create mode 100644 src/main/resources/icons/agents/blackbox.svg create mode 100644 src/main/resources/icons/agents/claude-agent.svg create mode 100644 src/main/resources/icons/agents/claude.svg create mode 100644 src/main/resources/icons/agents/cline.svg create mode 100644 src/main/resources/icons/agents/code-assistant.svg create mode 100644 src/main/resources/icons/agents/codex.svg create mode 100644 src/main/resources/icons/agents/cursor.svg create mode 100644 src/main/resources/icons/agents/cursor_dark.svg create mode 100644 src/main/resources/icons/agents/docker-cagent.svg create mode 100644 src/main/resources/icons/agents/factory-droid.svg create mode 100644 src/main/resources/icons/agents/fast-agent.svg create mode 100644 src/main/resources/icons/agents/gemini-agent.svg create mode 100644 src/main/resources/icons/agents/github-copilot.svg create mode 100644 src/main/resources/icons/agents/goose.svg create mode 100644 src/main/resources/icons/agents/junie.svg create mode 100644 src/main/resources/icons/agents/kimi.svg create mode 100644 src/main/resources/icons/agents/kiro.svg create mode 100644 src/main/resources/icons/agents/minion-code.svg create mode 100644 src/main/resources/icons/agents/mistral-vibe.svg create mode 100644 src/main/resources/icons/agents/openclaw.svg create mode 100644 src/main/resources/icons/agents/opencode.svg create mode 100644 src/main/resources/icons/agents/opencode_dark.svg create mode 100644 src/main/resources/icons/agents/openhands.svg create mode 100644 src/main/resources/icons/agents/pi-acp.svg create mode 100644 src/main/resources/icons/agents/qoder.svg rename src/main/resources/icons/{ => agents}/qwen.png (100%) create mode 100644 src/main/resources/icons/agents/stakpak.svg create mode 100644 src/main/resources/icons/agents/vtcode.svg 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 0000000000000000000000000000000000000000..7ffe45d524b0875b7528c5e407f34a17fb2392cf GIT binary patch literal 98365 zcmX_HbwE??+umr96e;N`-7O6Q(%m4X(ozzm8PXw*z>x0l1_41(y1|hmjPCAl!2A2o zKVxT4+<86MeV;>^nu;tA)^jWX0DvPeC#?YhpdkK424El|KKN!n8vuY16nSY$E%$HR z^WaQ#U44Puy`xf}-8PB35crjK`-$&EOte`M2GjH>!#S(ww-sIc)%9BX&1g2V3j|X8AelouJnx{ z=0toGF#s?I0R82it89{Kz?diQd+WV%CtJMyT8<(8Tbfs_2v+&WtjZxfh%)66On zq}GX@4Oxq6Z~9!Dku-ExSF+`6wkEo<`0CU4vQ&RVL_^8hZn{c^2{gJMJ-bHi3>B%b zR=%yn`_s)h^`HmH#K8+@l>Ca8u;t%Q`g_ld+cWU@9|fvw$K?ul7vBguYcr09r;iW7 zN)%ncSC1?N@ZcFHE_X`XdupDkuguN2giQp@m!E;77LO0PeL|bbS0K5$RSTyBH1ksQ zcBPAdUSX#dbedb-es6AHJiV?*F9eIMb>q+fa|=y1C6y^ACJ%O zpO^_96zo`!nVoqBmy3HO?i?PeN;IVpC^-mL^VrJ7yh}3>n(ZmHCeYUPESlL<%JBL;523aC^&Nl)eSdi4JtT@ zxwLJHemn9)r>Pk;a(3Rnj-rUsH<_GY4Ac04Z8FTzV7c6OSZ49L5*fF{v)$TSbz=LU zfHzOdpijlu4A;rIOEeA%XZyl=ysuWPG&X)eR!8CXs`7Zds|}SM>Kols=vU|44&zB9 z<-QUl3wq7qZg4sU+OX6)nVHMJv(v%g`gh$o5paPVN}PYy0***Pq3+ zgfcfDa-q?hdVBbd72(d+jSI(e)tB-Q)PsM5QTL*G#8!&z-{{hBdasqGgx+8ESrcrE znUfO7(Y{WtF6mUqpC;wWt^9>PNYPb9XsJ=ik;Bqm6X%#|8WexB~N__N1`s zv)(sD#Xm*Yi(D#fv&gSb8VCPSVQZfcSGZ=3gG_-}bgXkW8)lNTa9`Sy)i4shOI2ya z!$yv-u!p%!W)10k;LHX7Ux z%5tVxV!zlf&N$xON!RMTi_(I!?J8_Qci+%oxf}uS*Od-=;cyFpWb>lETYuar)R~b+ z8P60Bd@v2JdK0b$-5@5UGrw%?Lh)hBY*KEl#5kkGa&=k0KF#B9f1yZsKR@3IY6DB@ z9&W+<6bNZ@m*z<}kU1HmJDQOn#+9-5USeg8qnn=dJSQkX_@?jy9`{tUT=Gk6O90=xZ2^;U0C-3Qh|7*c<977q`6=fZW%rcY z{lMBmj2K%0|FZ#ZDT#;gH*!?xHF1AqjOnSA&7aONB;TuLB`ojXCdA2-kd#$_XReO0 z&iyqTY1)V-Oq>|wuD$5&dO%D%7|->Df1q+cKM{#)BF z4^cNjewvEU#6xJ<`uT^1-Me80@Won4WJ|wcq1z zZ=j^U@2CJ7o-rB6HK+qf_zw~@D2r6+(KI>;VSsyXEvX<6B3u>a8>g^dCHd<}e!P2d zbuC+w@Jlepn})aTDR`x`lJ3Q95AzA^kmtn!^JWrlN9DsihyC;q3QNyMyV2k6q>|KP8A1a?u$^3JafBwxCOgyc4~V+ zB?4ef354%Waa>tO1w9x=k7`*n6S6kqYaH0l21lN`)PMG{L_ii{e+dBc3NCy8U77hY z6|5u(_e-QIDZ|f59ObrA9u)H(>F&cC*hCRo(=;SxVZ2{6bZQhE*%RX!_?Q_N(&Oz- z@*@KxfG5+B_7RpW26hZA`}EvAL|sVW(}S}sfD#<}!)B*WJsW@A{UYn8zjkg(($G!G z&G;YvM{g$!oIi~{qKylny_XT@ZAVD6sJ7Ie=!|JS>uu}s@Rn&1z$TUUz(;g2>!HD)JOP^V9kO@?*4Y zsb%Po3}ZjU`Dm8KJ1R(d=Qp4;>ci04WTbIdK!Cb0ln_FRe7|96J;&(pl<$x%MiUDW z`UfBENdK(?UFx3z9z{_0fMU9PQUOvtIjMQj9_|?$VEwT?0q%d{JyY%H!Fvpl$-L(P zvhW2Gy!E!Dq{pSRpH=7zj_f5aacF!K*V`Nx0X&$Bc#;>m`R2e&eRJf*4VW} z)ODMPJ}1n2k$`|0?Maw=`*a1U6j^e z=wYE&nw73}1O$|7%pKbbfDLiDQTY8Ft65U4MVX_1?7gs1TwY@j#6UlJ0n!X_c{M0t z@W+3d8WQ^Kv;B>SVNWn4z#0*|kylOo2>GnuyUWmlvTxp*UT26AZSVs(Pl=ZX!%kT;JSAMt_nYp) z@H8E{OBo`R2I1145qD?#q@zbju!DYYX)B&V%s=C71xO!GcS6jyYVQW*-K9e?Z&45n z9}Tv(yo$L3?DI>bQ;ky~+&7vZdmGRh>w73|3R#o*NJI5LmHIJkWGzVQZE7%bd>+2q z6^noGQ#;N$LgDtK0`QML9!_u2-g`0KVE_hq0!TLr@1w!aE1>MkYZ95c+LRi=|MY$? z2v$$=ix05D)aCWCl+@3Tf$wy%={b^)0unNJmB$!d{PxPYO;~Q^106(3^fBjmvPim< zV#J4$my%89hKm6Yv+XQ6Lv?`wg35%9drz*9OdbPgmP7ypbdN}?i7P={?Wi^!{Fpzp znq*fdbmHj(mNUm|_Y)9@mPQAV=^sSJwDIwzmQ$C2nMnxZKg=JcWkx;5Iy?O{;PDt; z>uQmcQE;?XJo!uL@%Nx~yr@n0Dx1T-HXQ?&L5 z1Ny^@`e>Sd-tF&rO-1#t1wyeE1E&Yoh_V<;-{wnY5If=dqfkz zV)cgixm7v(I`~O?=o#7WqmQkfVfzKoAq%`6%7mF@vA4&t8QDks^pl0I`Fx@Ra75T! z*>Fx_KKcY5NrnM^`x?E%!zMfqd>>w|7WqjL3S9-J!dvnKq9`m4aeQsx==m{{n6eUJQ+|;b~5m0b&>Q{?6 z1>vgT*AV>-OZ5MuhCb*VihR2e5&uM4)S?VwZad`~hH8XS0Kbj^$GyNlAArR%?k<}= zg8SrQE7?m)OB=!L054ag0Z1FTr-FR@3=!Io+fo(*QlRJnWqwn%uSE#r_?6EK!xjYk zyfuO^anyg0b@ki}b)livf6XLN#}TXdpg!N+?>`ksERE_Ei3Q3|?|d4&AOb+3m_)E? zmiE0rrJWt#B3NX~@8uX3!#k`PZZgNK=AEf^tphFCS z=CwDKGvT3D!4{3G5`V|X0Rd(RmS87vj{#G$%U8U35*2t7P^40_Cvvc$&Pc=&VuwC} zjZZL`D>jD?V9XN#he&N0E`4w%Jt*5Ozz1+yfskYrt@74Cw7zf--PDr`G6wo3?67a} z2C*B>*DQij9WD_9QlTTjfC2RH{cawm9rPgRij*o5zV)m!f>Tv6;(9C22<*A;JUu|c19cftAx&`zEG=0 z$Mkz@Z4larFkyu5BgGx92aO=lAiH~J(t=>D)sDMu`sw!;6vs^?MYam~g3kZU(UeCB z4}a9#+4l$c*+yqw9)IvXTU-Tby*xp(4_^~-Az#?a@A;UcRNLhJ!U+7KY85Y+t&R(! zXGrw~rn}Jk8R{XBob7he28cfDUp3iR|K|w;(bhQ=L4chkRQ}j-R(zUsv=^ACz)Y#% z!PVc%4Se@Lx_H$;Hzq{La?0QU0_cL})`EV14xrCG2ofJNiqyU@x>7%$yuMPFkIqWw@|AtEi{9}Bb(Q??6*pYC9MoK7S&kN z2iC+Mi7uN0P-zIYRbU1S@gw5rEt_Rm)IKd!v4h>utT{k_U(fynSfOV(R|5=NdIjA6k86I-$Bc1-1Cy;r z+BtUo9`0F{5D+;@DC90;Z`9~Ziu|{Wp`0bCQfXx4#Wp#4pXJo&j~EY|yDVY%EH)8x z(trqg{F?U62{ZM(Zk2|K9^>(2j6e}3UMe!dsD+t7qC6Z8K3M~< zqpZablj!yD^B{OhL=DbU0)jyvr__&7KqteZ3lpXPv|US=h7U-H)O`m5*wBKF5n(n2 zHha5*c1_Mz`VF}WC`sqH2wCoH0Q$kOeuhgx?U_2q*<=3wXQoJ7;lo?hBNcQfni!0)mA=RF~ zv`b@5mL=lMd*o7Crj;Ez&>Q0W^g$4F3Phy^#_+Z$Hr2U-xKLH%KE$?FNgN#SSrcA| zu@a(ruW+Lw5xv^b5{Aq@GEM@$9G=DG=we(5@Gzd_Lq?DJ~%uZ2bSMLLwHy+QHXp zJcLgwHWmSR) zt)&eUYiV2RD-sXC<-6dxZxt4-rItbVD&=nXT}X{cE)c3L083{&Pj7qv_zTyW2FvEa!ol zWtSCb4w(RB>WL+w=wg-oZ*(3>oZ*{UOj6uN2jlgj=Z_qWEngT}Tmmds)02+wBg-R*An-wAHkpJfp?edK{-xoYmRE+rIS@<^afJXR)6oGnaNx|DCvb}O zk(XjN;Q^M~!hrl9H>FaDj6vkg>t|ai8&}CZrZ^9t8P%aha{YCSIrhI0G|&ChP9d~^ z*zs@hQe{K@VNy2okE1*7eB%YY!Lx!RqEhOIyF!^F(MT_y>5*nb5>>?>2L^Pes*6)X zkU;Tf&3n!)X&di+$qXLCGxW5^czp+kcS{o4@3ZM}`)_{?j1-_geQ@7Os(|n=t?d2&U>8bzWng|sgFq3 z+6tF-gH6ppklpc49Z@j>9?k>0dmcDye9y~j8=x%fF4T~=&>@lwIZeEd$Kqo>Q+*|- z)v$bV2bq(_c+rYBvQqrfBAxVDoyN+5Rxs+tH|hu3*G)CtH_=4YPsK;kO&2dBhaS&6 zmyX8WkVKU;(jKihpK<;>#~6Wt6dpvblV5)X+i?EZF+>mP>kt84Ehtzvx0+m10%q_Z z9KQ}A1Z=9sc1m8ZMV_^nZ>VNzo(?Mj9vqFV81v&3kty@Ks(0~#ukX4zlQ-*u>M#7Ey?dnwhRYR41IwSO3PA+(tY(HT zO?wmAllYp@4*bv>sTU36VQ0w3`70BE7(lKW8&-AK!hgm;TrHI?h3waVFLW|eGNQrx z&-w2S*Y2SmAIff;eOyxze+`el7bafliR%mA)dQ5pmMaqRmkI$C*ScW)(x^%aEYQ9`Z0b~;)7agV&;-gccA2pvZU zY@;ZVbIoJAJI0yb+ewJ=6Cuq2W1h)=dUrHtnVanl!S=RKOKjMyy z;{estxaft2*H&=uy7$hK-UqVayw&+ zPIDs=^L|k)mIJuzZvd-W{qWznR>T z9X{@$rHyIQnNbQQDuj8Z4O5ZhyCB?w_r(}UsGC>vI{?w&7Mtq`@j$x2l`SQKyFIH8 zK^gyYO?LB0WSwtl{181$xI9>&`ux7%gfRJ&8;u(cWKN1rRgwR1s*~D`XkG%8qJRqb zJ4w&ZxdiUbdjQaqlD(yp)gLkxM&-R(wBi8>UO*G767xcS+~B?Phl-tX54x+_!ifym zXAiRh`4-pAh#D0$so{O+o!gwngLF3~hR8JB`meT1q8R~I3_m~X{E9nE3QKFkPz zAo4t(`_M8SeMv108nil zNm@DU+*PDpq|4OU;v@E;mm^J zV^_r_FAQ)`vN($upjrG|bPwaEvOs_jzJ!@&WP18|%d!n7 z`^U2p=r`ECR(D55?WRKsh?Q~Cd7d0v7L8m5vJ#7Wham$Rc4hY{a%KnNvxNhqo2ckQ_UKwwHk>&w_WStU9&tk>z7{8-3O*O@0K-3fY z7mKI69I5jz&@&d;U8{H?QQ7dCSYBDUARnR~Fz`+I4c`g-KL9M4*t+sf*~W{y$wO5` zGq3s|{0N}g$rC)Q#EO|im#8fIc{l5VLJZSswwt2o+@M66=NXOcdr4lJnc@tGXG{#@ zOf}dbL6i61mDtlG(wrbbU!p!fqO2RXCL-^G5rUs6! z4@UMrMYYVabjjuz|DHUPR^~#vWe?MVu_Npzmix4WVeh5!?XHpB&(x=s65~wZ7B~v= zfIFCkv=o@4Nz1(kV7v8N962y*u$!9t*y;*ke>cW4uxP3hpsSBiZ+NLt3>V|=-xxSs z#`(mG;@008$y)F!?QLqZk9~?~hjkb8qjP{l8fH144noouWB_fg-@P-;%f61OM zFISQ**6HJZh=Ue7>MfBAdb1D^lvxh7X=e7vfr9iLRw3tWlMP?uB zHz92|Ks-k;1B)6M>f22;UL>C&(h1kn*&cI)STC%@XiDGhP?Jz{k~q4`D#EZ~^>%@- zEbO?+Ncq^w3Q0c0+itl*P@O|yM#xH0J9;~MZ2L>S>9+3&=jh+Y(KY<@F3z2FVWI2$ zlRGb7<%}pMjkfVA6EopNjaFqz-h`r+&31eoI_DfjA`N$03(-RtJ+Y@5FlxA%_g%Vq z?~YN+o#OiK6tu(U`wM-1rnN_Wd~yD6Y~$lX?;l!xyZX&&}D}9-e_x!Dcte0hOnPw zLEn5?67fh@#NzJ@;hNFm$omISkj`0c5BbpvTx?dpwW%L1xOU+`-@8zU4?w5ee72p( zjxWDd;)U8fA9cc-`1Q8yfF#=u>(o5iYclQjw?E%4&HFE-WgOkkjl+>hBvVO)lZ+eQ z)V8$MKwq(clJI8sLBikocK*9~YRFHs34O1^a>xP!e{~j%q`8}HmA*w2Uu}4m&?N*6 zVBOVg7k`(VwN=Nue<(LKBFA~wf^}nr8eM*@vBkKwjEd*u20iJf0I|pLVkSv6eD*z~&-exDI`iQo%@%%JDm+RCbr+i6^kOf{T?jXR^o~9~O(uF^U*8454l0J~PDvj_1DttSn#g0Xsk>>J zE?o~WaF($eo^N76n!7a2+Fxr))1_H9c9`v|vrJ|(V}%ws4r5(Ti(Y+i`0aa1E^6rk z$bD9{4BusH*N9RA$14aqR4*WnR<|(Sy}+#ag}_N&_!-b@pWS%|CTF;cldP=+yRuLn z%}%1QA?M2br0yEpi=mEFEXb{*EV*Q#{xA1!9BeTsK;td5b5wx@&GK$lR528(JF{~D zg~>3}HPTVbY{lN*!rr4S&F{1`c)0GEI@OElGqS~3g@5xg+?;Uwr`?nDuhq^HBADp# z+sg3$8yhj0CzH8-xol#-p&n{rFL-xk8A#$!XE(}WXy@6BjX*8ld-1P9l-6)8w6|Tj zi*Uhs+G;_C(3Ehw5j|P=+r^dWj<;Ge-2;Ho{LOg#h5Qy^DGCiJduqvucHWXXq3^H> z>}RqRB^Q#5?9rSG7nK_n)VOF%;rn7N{Zd9_7s`yBs{sx%jLZuP6a?S^AwNmkf~Euw zM|kD_2mv)YB8lCZnZSCy+Bn5rZyMII8W=%b<(Mx6P3v|VaQ;;02mfF#W*+!Ur3H+p zO8P46`sG!57Jf)Uz5YAp)sP%s7x_9$qD@b1IkhjYY|o#)i(~P~wOid2tghD!ie;wJ z=!MM{fIeaW+#;WCCo3`Vv)dV4#v@H)%a1z4(}#8&BZ6gAlu`sce*QeV^>Stwb@g-XFxS$qih{f< z-8dO3J96|7|1md6JHp(Uni7Hac1)d_Jc(S4o#MueAq&^nW*(IuH?A?&<`>S47Mnre zt2#5$m`#Dc0B-|Pj!=7U>m=I-M0v-!wUBMF3(>K=(yDaK$mjc#btYOL)1-Q}xQV1^ zqlAlEx^tScEahH~yFURjm~BvR(Gfx+y?757&S8-1c#X7-L&YK=dg0>itcFiu{wweSf8H>*s(=0Jg2JhO9Zwwr=RrAYP+D zcr-}+Nw?dPLyF6<*-|$XavM3gJuZ=UsMV$bCIR?2ThTmknZm?h3wj`FXYZ(z>||kZ zi@vqk#ELVXPDz-@m(jKxf$2|?#RLPIIe5-NRz|XvVVytCd;1O>%cpgE)0+q3>_d5z zino%A`gsi|+kjk>ZAHtllL2u%IJ@M)05F-AjV&Ul-auv`yRf(rO*RHLI6q&;);dJs zQ@koqM7><3>X--6VzFZVxuERZld5r$3H9_t@$W2sBdU_KR!c;+;Qe#phMZS?P{~Bx zyyyL^%wmXMWY(Av)UZr@l{5kcK&xdR$HFooJ;C90)FSv%(Zi)!x@5Z$o`zdCKK6K$y2)i*TI zCm5w6`fRHf;k#Yc>$|C$aoZ(+nNt<1sY%ZJ=lB_1u_D%+S;Ng;82X`zC?TJ*siDZj z3(8RQ9L;mwhR$_{IAqcyM>ze{ra6g;Tc13`g69>RZN2B!?&J$MN_CIMBWv*Gilx}i zSo?LvyqEo+kI`;QFzB@Jc_kUd-%9$82FjYh_h)s+z(VxCF&I zOGUb~&>t41e)i5FtaE<(fFbSNXz}DDm?r=f`I^YKqW7PZ(X^|btI_m7Qd_Cgzt558 zibvPAE4(L3%J1I!krw(386(L19548D7=KUMVe9K~lHADJUm}1VjwktLB2!ryRJThZ zzKdtxi-%U2e66Qr&**!#v8vY5v=gn~8y))H%(WxlcA~z9={itFZ_O!y8os6{R-cJl z7<|Ctc^(K@N3pkNV}5lIDC|A${Qas{Q}b(Me_BWXCbf9=0~H7xXe3aFGk@}ap^gLKRcVY z*@k+w`vgbZQqotQH?D~H{Q`@})G8)(=zIpZ7WQ@hZl+y{vh~R(>psfyAbt11fSil& zx%J?Qo?X>FU-f@IFPa~0*2L8Q;tOU%56MsW!8eIww^h-;WAW+dVScx=$P`6RZOBk= zCc)m(KQ%)ipX~UqF$cR9r1~lLFjxF=p35-Yl_Lx8QxK-h?Tehq?iWFNT3Zb(RzC@} zEx-7_cX`XJVdsdsq3{jLo0tC@DI_`7*5;{s!_N2BfIW}JlW9M*^=rBI^e>@SV#E{_ zQ20DrpWw82gxR`k7yrT7h;_tRpyEcAQtb#qqpqae&J16iLNELKC&5@6Kq9p;Es$g$ zR`au$07Zi@A6&W*V~Zs}zXm397mJ+oJ5|iCQ(k@Yrf55mSB$OV2f#9qGX9Z*o392h#f5Vn!H4#LA=k#&diMQc|;{Zt*8HY(6Qi?5pq&jq3?SG zd2rcR^cn*|g!_s+kyW!3A)5*)zy2gT2zIYfu&S)EdG`!A(@*ixbM}1zTXI!;q z{K?&31x3@Rz(y=BEgB%MYZ_F%pK@2@)^^y+@M`prB@@ZOoSVD`@yqr&yqZvCG$#`0 zDwh#wjf7TQ9?j8~g%kF>MLbbe+j!Bc=PxGMZG)?J_C4d5Q5h%UF6s8XwQ;w-4Ab1_ z>0Pq4k;OJzU%r;)CF1MUKDXAxdJ)7PRE+dh3$<~1OpzrL1(^N(b11ZHP*mts4#V$J zF#wp0K-Y-~Z#j$db9*Ywer;r^z>|o^`c5I~wHc|=o7{SEuc7gG2?#9*|@3LFS)hvA-h%8_-qdpx8nLn`y<3YkZaNglNOH!TYPjk>vvJTd)d;Z~P`gy%< zx0PhC@2bg~5zU~$@`B@UZDS^r9v9{{h5sxyJTTu42pIU;x81$qLdQOdDrl&FsBCT4??ZdJkz2;%NP|MkoaB|}u~+c+EVpjH zjwX}0%Z9rwO?0qMpy?zvEVUTX7|r$Wy8a;7T0wBK&*0RU>})ym8$Q|^G2*+@<`-vA z*gfa_oc$eA2;(!mfBVm5PinB6Zrr14q(^(I7rbqTP6yg?`JW8vfAkGDgOBZ0F$x}A zEdQgCWF=a9u~)ZReLC?;?2RzIF^8k&n%iH}Os6H8k!D0~)t`Qxdc)g{FeE%If#d7b zp40m31ZS|NimeTnW=Ut*wvQ}mtBf4cffWa@^TmEy_Uw3 zmhNXRMQg7ZWxdx?6z%@A%hTGU!wm(E=?=Nw((wQqDyb zl2m_4pI0McPlfCnAVKALho0sJV{wvt8Vv?i~B1K8LrVh0s84;th!dYU2hN5#){-@V0nD9 zC87ywDQgNUG;2zEZzb{~1);^beIdfuN=t=HBx1|4s?EEbb@L}0g|F4D-2}lz_Tur; zHF`xwu4klngFiEy@}{MOq&Vc&tL@p-5iPM;mf_J4Xx|#JVHK@`A2l76z{nEzpXcqK zj9zOZby%pcyqPlSGngeUp8fv5LcnWJj87Q03pYIkBoFp#a8|5FILD-3#`TZ_8-ZwC zb8D^6L!xIN`H(dfX(hV*UaS}ZcvV|clTzDP1X>UePiIp+tLt%0HO`Gd7S>NXfzB0Q z-9&b?Bcf*NoUOGmr9X$-5Z)wldm|6bkEAE>{y|b0ZC=s2+z2Q#)BTOcZ#hY8y~#yH zfI^gv`9bT;o9DYOYw;OJ-|jT6Z9Mj9wKyZV;4>n_#;mw(`u(*8%F&Q|TWJQ$@Z`|E z@Aw)+wMcaBQ$@IpuA0i0*o42VJT@5oHhbS_W8jmwOcyC9C4@(v;;;A#4>oCZnB=%o z3YoM`^jI}S3%r8aUUQAs^ToLLD>4nmER-m1O~9_b;4WF7gdz1(lKeX!om$-;I-!W`m{_$(0V~ zSgSS4cEy)_E$ds(doe{y7kk>3(n`p@eoj&M`^RxU(t5^*@ldW;b{pd8di9AK;6QH2 zH;EZH3|ew5GMzSd-3i$ByAy4Q)|M*N(r%0IJ*vl1Pep6JcB1()kW))8;zL?Mr7=xE zw`6fw8+JOH-N2R|gl$~#i7@kRH{%xD_o_rfWkn~C*-8*uA-;q?d3JVu#acsi-kM+x zG5{0>Dug8zEY zH%VUT8UB<3U2M}LT_{?SH?1q>?e;vj^&;*&&F3k(i7jA-9;3I&6!}C{2m2w5|8^}) zzih#j0+J1jqt=JIbixlAY+vR_sg)Zt2E3BD=9o|v%hQkas1*rGE)E1pibm;7EB3Q+ zxOWb-?Y*l099SGj<9NhNpjOJiaYZbLCBm#@s@GD<^>;y-s%JI>*K9`pipISy-To~`cLYx2f5 z?%P{RH1lVfMaoRkKR+9&>AJF^jth z=epX(Ro$hhc+b08_(H?pcwj@xx22 z`7{4k?fs}plhVz%yt^sWSn(~O)e&ca{FTN0=81)*%-L7jTI#yL6Vh~-j!%y*I)588 zH)#hqM2}iv^nJdf6W!AW-eD{A&KeqpanZYFrk?#is6wW#s!@w#okY$*EUt9Zr$!3GQGw$gz?HiN<+7i$Yw){E!0v1p0-(>==M zxpP0L=HU`NeTy)%guK)u*3}~o8#Wq9Xt*M!ty5`fX}43LM;^j1=k+afC&!+Hb0XB8 zBi}Yu-jtC9si0l%+M8zN~+A zlFAtR^-!Z?Lk71fe>u5m0w(6>VgMe%g7D`(^~>CMtq@=A{-TU#ksaji@VX{nX=Z6u zbou;!w8xxGG$m?K$Ka;Zj!yXY(b8eunhtx;Cb+*ru`*JnEr2(b#(bdvIM>^!*cRUk zOU}o`qt|s>wDWJ#Gjfeh^0|J-)In=Sa?8~~u#HeYp1dV8L-7l=XzLncFnYJBawPrJ zVu!0K%XBwbnp7<_3OLv77}ct0Pt^-n_S&XcU5HP9{Gayfzdyf0tGWTjEtCJ8h&~Ic zh^?wD9x5+tkZ00T=5jyNEUuJjsPF3c{5i`L_gxOfe(GB&S$j0uFs{_X+jmoSNxe30 zN+izjY6(JaRKHG5&e8Y%D}rSEaPo+TCH!p^IByH!;Qlj4(d9Hdp~w_?#-IJVBFc1` zkZwbus$@-02VStXJ8UThO}re5Am`LyF{u|JG8M=h9sJOQZ1@e9*w}JWr)%W1LLeI+ z8l3rDqm+?O9HrLw0fRsg~m(#_~Dto=Q?4#o=Bzzn)P0F0>7w!fKuC z55F}Qm5Sn!YJEaon9pxDPh;wbQtsZ4Q<|YCyUwKIy%L$^pD6i)wafM+Ek*v-3a3SeW-^6> z!8Bj}wS*UEW>1Dr^Wx*^wcf-l>=MTD{0^t>0_`?h~3xqOdocnyL@dmkQj)Y)DV z^S8xmaJ3683Lh++`4wcD+1Un%CAGF1{J6W9xQ4%w$!l};?D%Z$ z>i2aG=Vo(^rjYQ9`k2%nRYZM!O$be;d;8Il@LlUwZj+zpMs?Sflp(+K+G#b?j#5ig zY5TaQp6QPJ>BW_X>3pOo6A|_^ z)qsnEhBvyJqX$}9e&KbkX9~iAf8Cz>?VbmLLbktMv9d_Gjm}0bW<=|Zm`v1ze=_;C z5TS5Z*j5zhu~am2TcLKm-wq5KD|hxh5;hz|OP)_UYHb%g;36PGITNAT5Z!Yc?iD?) zI!UjB)3YoD4h%vS(^U3;4&|TV$^Ld-$=dUBP+{L2WKi&PWbp_{Ak-j`jwrDe22&iA zTKe>s8_6<3qk8Oo)Kf~HsVDd&U3iA$Szx#`$Kv1i#~`ypL5j`-O?Wxy67fE zj#<-I>>WE4gUHG7;c{a)vSO1LWAVPEGhW;>J1_j^#Oo5o+?Gq)8$}Emth?IU zdH#WYeVT0&ej~qz@_D-$`3~&Jw;EM@I+!H*Rgv*K3ACtO|K_L&u6`iWCTI}p{_e^` zDiSim#L3UQr%DCCb$yxWn-m*!c%>|L2QN(;P#ChR)huoDiiAt>;$>-+v43A*mKBAM zDr!qW+n(&^slK)j86W7TQ=WwIrY=&)GTtTSGpZcvw6LH=;eST^0=Iv)_U$O+u$!Ta zMk2BGdATMuMUBLp(T(h@{Iv|sGIQR;YnheyIeVp5 zp*MF!#6Vc)C;TG{;+4D<)~ZzHem<{GS2yKA$1PvVepw9VmLOyKO|9j)`V+YVR@|bI z78e$oH52~`*@y+{nk(o6H2N&n;P|kMoJT)?6sKYK95)Wlb32hWDa%3xM+S3c0O?JC zV#?&d%j0pCw)5p)^aKnM%WT--E_N#O{3aSO_Zer>n;m6m@5G_~OvZzX{0;9M%;a2FHdK~|NLwH_X6@51-R8F|>zYm^UEthh zO00gE-6>Kl>2RB+yhBP@YkESb!8k_^Z$*DVBQ9~H@-&4K9UCZHd9rLWqf{0hFikj)+1!~zFp;bk)_lAh2Bb>xxB#N5v5dKVM&fpizh`|JDL@H z=Fp5^N}1cL;TPRYMNV?zEYQ*8)b)0kCwu^A&I)N9-$`v_h+JD6?7q>PR&%e9~ z!uo9Uma?Q!raz70aLA@5$c{7ZP(eB3`t%3$s-&^TOD#!&tJlcQp5Kmn63ift!2#pl zZt&P8cfqGK3oE}^tFK!x6wt5+JLUfsHm_jo#2c-rkDmr18^08FWt}qd^-H}Vq&~M# z@ni<=E+|!~TePWZ8TViW}n3o;21DoIv|y3Y%UHNnA>6Vd-*h->xm5KPADDm{_!&XXDDuD6>_`k z?(p=k)YgMgySb%F;0sQ^_=<;|;J#N^ zB%RhjDNCS|szGY2BWkS_f)tsVxp_0SuICCAK*Q*Vp8LFe-u=u9;t|zo6w=hiZGN-6 zfu*7UKcSmt`^8$ zD5R|c>YRlT6%!YnMTk!`+2iQbFJypiu4H;7b zaCz2zR(fq8q!b>fr8F-)GqEk6%)8ZR!*6adN%wv--g~k0I0X_m>-(jI=&7(>ArPbn zIY%9F)a9366*Ra@3VBlu)BarHKxfFwUdcddZjw6kN+(CsKT85}xGDtLxpUW5mtEy1 zF*-z4rL&SY$n2ifT$NGAXp%OdpG*saGEPt#)hRELC;Uwh-2e%A=eyqX*ROny26(Iy z-J+=o0Bhz%a2FAa7RUAR#~=3{&wAEKt1zm}W320FQG>Kc0nPA7y$K4M0aDQ|mPe;i zz5wDDqqxK4Raaee$t4%Bt;Xnxg0Q4%k}?I{T`Sckm4Jw-6-2xqM>h*0Cony&MyysQ9oOE@a_!VR6P+@<*m19?jB<_vs7D^^X92aD|I4? zrm9sH%+2%gt_yze!0(Ipqi&kq((&^|cr}qc_U7(GhmU@`8EPt8Ueb*xqH-7B;=dp| zm^P9$CW^Lm5H@j*n^|n_C3Dcmv2(wFD5BnwZuGRV&rW{zP@P;F^D&KJGg${P_1T~) z{ucmZu;`ava_NqhU2ai3O=$$u4(3^g6zc6wZ@r~dyD8X~(mOSE3I>o0)6F0D;D?=m z{`s*sRn;mF!_zIspV;fPUcp2QuBn4l&Sg7Baan7wN9fK?3`2#FfAXKc_Xob`Lm&S8 zu#`IZilwbK&lLrLii$v0#`XBh%P&9s?0Z)(K`Rkg6;C6Yrd9SD`ScHc=~LZqPp|)H z8AR0Oh@D4&?{|IwQm8~U1Q9LqJOrtYlmQMCGqJUL?aMdb=r9OkZRu;8>6vr(C+~8* zn$9WPTl*=B7~G;c-*upa8JTz7Jf$~5YJjJrqD`+Pk7nj>o{A`PHa3s#)btJLe$n8! zFR&RK-lJJxM;&rttG{M$#NaEl5|0c$C7N$uI!W)9J^&k7<6!asA}NxomL|&kb3t>7 zHPM-lDmCdxJ2c9)eqmio$!XY1pZBr7+t{gZZ5P;^dVuL)LzC?(hoTIJ)GW}M>9!5v z%>k%L6;yzGo^hA^-RJz$-o6*AcE4)fhm;_=`2-0y`5%*CauLg!$2vz^kr=rzrO$^^^JLJbkPL*Un4eeDD8 zbKZE(R6KRwwb~RxN{ewrj8;e3t-E*s&L8~2XFvC^A!wRAS-q2ILnn#%>q2H(DD5AZ za_8=?Vj}LHeth6e7QHvuownPH8wBPt)?>4AtXJV<_py8JHnw^-)~i$7=X#=i!@C*EZmp67Xoij%TL0jRazr+-c4{caPUUAeW^b>^lbcyW?Gk6E ziMPsPQ$^$sD<4-8iw;3mRo6b@>i7Ts``7AP;3C>>kkB@%PhW+$0nXHJv|n7Z0-d?4 z956rS#8V#on8(Di!P6Ft!=<-$Kkm!tD3_Z~wwHD(os4q@V-D>nPG{K;Z-*Flj|d|j zn|s$AX>!l@) z>1(A|g)yhht#eKUXxrnvc|bV%#FL-3PiRA>&zo$hqnw?5zp$a>9?hG1s9kHtl`tJ9>@0G9k>uAxe z8H;$qUE0W9bp4&!z5(K<9EYvXyLYUqoLeu=$mBINe}13uSwsL(0c}y%peCVtKhaGk zz{BL%OM7qnMw`oYC$54nDNUyO^yw?*VDRq(7wa@?TPR;Ulw=35x`X?%Z}-~%r+ZUC zr8I~{UrjBmNv(#=IBid}ZaWk34-AFC|_Y8IsgGwDnETQD8^_m)h>+)X47 z0PZ3x28BNQ!bjcp^wZ5O1hr@uqXumbU0G*}cNaPsUiP?su^pDDGa^*`E+n;sQmm|0 zP>f^!Tfg<&7e4&L^Upcg>O3=C$^*cVdT(kFm7p%7)$@PkM?e1Ik6izy8!X14;<=Nj zV&aRM4V->GCDHUv<@8mRKJ6R6@$UDyn;0NsZXsx_JymF!xuiL(#mw;8&wcKvfBxrp zE$@u#E_w7T>9pWV5q~?pWU0&8r>ku@2$>{POKk9~x~m<0?EQr?xETl3^i5Cz^H^2Y zH+|DL|JaW`Kg=qNC@#I?vNyc$b+_Dln@XC-qgnC#-@I3w+-&Cj*SGRbxlMvne&C*~ z(6$1Zk!0sBzrkNi`jt;q(@v(IJkbaymm^w)OxG*rYU+!zUQ9%-QWG4%cGzouqe%-t2t9O z9TRrHx%025yqTn-QBS+$oxbDSpIKEpULCu+s=9el5x}EsP*>3)7-7{{pZmNY*nQjA zmX}9n5rPmZkKL6|C1uA{L-XO2H_t>~YT$FT9xq_s)yUL;2a`9cE6r_}eo^KI$EXTd zU46N^r6HTlJX-Dp;PB{mv^rV@{Foz-I{mb}h^U6p?3jplv(Ga(U|%+V=IdMkF5O;5 z_vU)K-)%J5X?CQSvOCxD$&!|;swT4Yomo%BJzp)|Ax&dQ>92xlJp&1)yrent%_s=% zls0x>oRuk-frL(nBbmNXO$QrOO~0i)9Qwp$@o)Ll2{6S|bL*L-anBBRrN8RI^xl2C zH|c!F1zUj5jI(*=>b;{fxE=@6fIE@~k#~0$f$23@T?1D@JVI0@h`E`YOqQ_E*eYqu zn#|1MZl($|6M?Em_?c&&`GEUhpeiaFW6ZsOn^NoEX6JiazBQ)ql(TPM&F9T*Z!9o4Z4$HvhZ7d+YE1-tV{rDlwXrIU?LWnyIP-ZdR#i6`uCAr#|Sx4-n*e z08%&SQtQ-i`Cys5yO=p3Rj9;0O?@_6T6+4oJpGhYP8zSqP*qw5cS|k%;V$YBbE{2b z?e*{c-tWHq1MgXl^=QXZ6@rMC2Q12>yvAkn)z%~ zFYH?&Ky4D6sH9vX0C#9m03tZ|oU`wJ)>)&V0#OxJ3970hDgp_jK|>I6vmLO^?!$u@+4P)hMWjpkY*1p-Y2^XdciRXl5jDHfQyPg>cU5!ddeMx22pi~nL|YK>|m2IgG@ebr(=UOB~Q6SvO6>ch(Gr6 zkGsQ($9aryt|}T-M5hng^p`cI*X{g+{o~MXl1#nV-B#%?FBV-40&(%(d-nX+Z~yi? z-tl$`A&i0p4i7=xyqUx`h+1@ycKq?jKks=zv}0+9xC%tejK%4{Ca*P>Wy!sOtEy5+ z9LK7vJbHkfcE>wE^XX4-x)h5*plJexywT;tok-W+ zeV;mLgH61q^I?hn|0agX3{3!YF|I#hi%sOEb^)8J(~X!V5&{v z2ETXaYG<#f-8?VpAM4zACbMwUm+9%R3?Yb0G!v0nM~ii=*J7+K##q<&cwDcoxkZm= zb-foyoM;i1NG_b}S~s0FCA5t6X)kqRt9G=*T&mp!Hii-{!t>bZrG8W=);Qh7F z`{qIW>l!~5;H8Co4rZzj$tKca`>aN4>SDMo% zy$|~2YyxF$RBsz@INV^hxfxtl0F|3>z2(P$>SxY5``nXHIk{e4gZQYb$^b1A%|Z~5 zt}gC)^usTF!Zp{v{1vZosMq5r_00*fnz4~$9fMYIM}$Nl?Wmsf?C&_{*kjgK>q=Bq z-2gLHX{P>#`ks3Af4_XgkNm_>-2Sz%#o81GLy*uMg7%9B10d^7vmkB5n0U%uILMX zaB6GzHRveqpV3))(^L@s*MW)c9$u!s>#Vz9sWQQ$iE0`)%A-BtffpQk#8Kn%D$+C` zK~=P>Dlxz5Yd3%NA3kPo*!MlB!_u7Jb2T0PW9s@i%iynNfl!AIBVLlnWO`~Yz1>&( z3zibL+$VTy#god7(f8`!xPSAr>)#tvYZqx^+<|p8kw@=yB`gqy8)7sGa@7@Ay!a1Z z9HXVu{Ai|OPQx3YI1im7PdMO~=FnAlH@ExU_x|_0&wb;#4ypjmpbB%>vVHD@JL`G> zn$r6=UPZ~2csF5mW`aW*{%5Fe#J!GV4eF|92rA=Pf9xMW`b)q1i@)?s|5rkQyE#NP z4F{T<`h=k7=CwmcKllSb{OZ4by{+xGZWqK(q}RKwIm$}h5zQr3>TWw$Rv!4k2YmgL zo*Yvneoe6&I}?ev3E(GwxM-j5Rp$D@M*kDismwzTJKS7@D%8C`|32rRciwq*99Na9=KOQ- zfR|(84s+YFbL9!wUHzW-y*s+8s#~WpdFDP2zA<<((;4sN`!=%glPel}JbQblm+}7J zb(f!fJoApwTVIm}-}eOFuD5>-bLqlRlAe4%bv~q1xH0#eo6mQyctxM+sMu$6)q^k4 z;i!KhpY@*S5$>nTQ`(13*>!s@?X6Lyyq#v~X@-*AQs!+>JC7_%goo+F<|PvJ=D+@& z2cl9{K`XiUx#ygD)>+~)M$-^-4_ztW(FWi${YAagCX#v=r>H%eSEH4SAAiZNoja{I zn2&4IAWci5{F&YcBTt^{K3f{Po>6=Sb| z|HXgwmbboDmjYbtv6ak~K(vl&qA*RBOlO>N`m>+?EU{>4sz&mnsarPM^ON_{++E>8 z5ZvH(9V75f-}LmOjy%Sq2|U)dTV0wIiCFp9a|b|(t$j+bNe;9 z6Ls4Vx{aKg{~*ow^p5zZMAU-G^~mhE`tNlAJGi9zM>?-`h7~|+x1GBGrdLw?9|5ks z;;N;kr4Te__1f{5$}`E`jZ{S|4WfGarI+6E)Kg&*4mX29)1=uO+1mco$>Z5+H^0sy z?k+aHMl5t5(Z5!7w_(;6E|dm_qkjy9)7>B4#D7C=^yh3t{wtkhh_)ZX@UfacB4wF@ zLpZRjGW=N5*kl3`#PRr;V~$>487+@Sqfs>)>1d>*pj9Qy%cI@5-KLWJrVkCLcF=!f zv_13^O1b;0G?%^dvUeU&EkF5`sgGt@zsP%N`KfDhx3m*&Y}kCJbAN)jq4VA)&4ZDg z^L|P5Pa1M(_L6z8b10nl*xD9BB8YX|wPV*6S6vyZWt3uhO9hhMoJR9>;`iO&C?PqI z0THR!YC!LCk9$7;aTg&v9F`{8N<*edr<5vp%|#hYN18n-GaO9ZYEzr}iLrj9OyA|5 zU2ekPCi)jMuVW2(RRth*i&re9pj30RC4<4Gr-eC;(?T3w4Ma{m{x(p)Ib*$ineTWN?V zJNcwj9`m@1LN$`o(>=zxA9rj9!DJR;Iuf51>2|)MTP!|MnllyPd*{LA2FmnD+LvVV zy{T1Cy)e8n^UXO36W=xPv7xok2=C{81bM_$RTVcE$jb6)d1Er@@;{MX+VN>z{oUfjpG<$LICSFh{ZfE_xOIf7(!IinTx?AJj&1&Ml_DusETI#R` zW^ok4sH!fx=<&xKb!-q-5qER5JdA|2*(67Eya$536fRqua92b`mzS3x@}P&Dao4+~ zu*Tp})Ul3Mo0(b84>Us>m7uHap}^qQX^S-T4w}+HnZCF4c4w2l$zr&B6!`78-3EbZ zfCA#C^3R|7=b!tzpK)~;8`pKZ^QN-+7|qPxJ%~d5=p%OB`Lxp|7x#O~Oj%M~#+7DyZw2`jYy%R>^HaqWRZuz4a%4=4b!wf4;D0 zfTUr60ELUWm%}9KCo!1>yd1!i2kFdO+g&JCSn_nFTLZbrUzFuyEOH{XM~#F>ucu>$VA4VzvTMgJR_#<{X_@1X!u(;9djZIg(qOVlkbEI@V)x3nJ#iMD!Of#Pw4JfQ;qDe=G>_3^ zOmza@&XMJA5Y#cI;Y!PQ_v-?3S$Gq(NNEMsoX$4h@}_B+`||Q~{;UKy2UMW$K92S$ zFMa9T-u5da?!R0P)5YN3M z+7?sN{ubI4;fiL&RVkrH=h&1d-Y*573gCxd>BY+emFWdbns)RfoX+dseqboYADDQ> zXWqPe-!CGfxi^cYxp9`3m%r`XzExE6uO_8ecZHbw?W?P|-FCYpBIl0X%%E;?3G#@C zKm6SD&J6-{tE!+uOZCFkH}8uk2J9z+yyyA%D|zok%cK~dcDQ@l?Y8DYe=Vi49ZL3d zI=QUBS4~<1CKFNK^gK3KlRLr0LKEv6zMPi-1_v1!(9K*o;k-y^e6fk#l4u$Mwwcav z?jE3U-?aIJL;B$2H*5%wTKdaR@pZRTfPF7(Ch$N9?-jagSZrWt9*#q~>Rm5Sn1lm97xj3#<`m%BS>i>TAD_{Dmmb^#KETwE&ZtE>&M^bpJ?g(1J#RX4(%2QUBS1g)} zLQ+UCf{=!SK|u8J>tFx+KYZDr?pceq!#qtBTuyVU083x2c`9i1XJIaBsq_w*xT$wD z0h5uxpj*u*X!AE-mM=}CQ?5@{LkJ?0YDx0bk{^?H;XHM5({Z91NwX=b>?iha6te%q zkv8UB?`<{26nMukJmaCAN!SUMHyyj`&@(s7$ew&$<+?yF3 z;s#q@8GYRou2vCqtLt&;Q<4wGWSQ~8`!lsK?OAq*d#-0FKN6%|P44<5VrBwp2&y49 zYHD>au6##-aJZ=D=^BwQhPg}tNaGquOS#khiz@5LJJgmJ4gBP%QVqki!%I5%gV&st9<}}T z52cL_rWfWfsUane&3qee{DyMT^V0SwU4b2)RrFJ%4?Px{`=tlb!o@G8?MJzRO%@)e zLF`mQ6>h(M_x0C*(cw!gOHg$~YT6DF4O%T%4}IuEPe1)Gl~f)*^%H0U`>Cx<|MZWZ z2n6H0hPyzPmX|KS{E8!wJj#*B1%e<{;&2V>@U=awqLRk15KWV0rPbsKwt!CYcJsaE zhTCiU-rX{9!iP3?l>WaVR6s7gOE+jq1A=O(A^-O8|N0|8{6n?XfCtratm`nI_I zHX5qvGLGJzbF&}fq>A|nvDCOf9V?3l$B{dadfa0lXVLQwUqn@qN`@V-V(S0(KiB`r zPd@+l+is0&S_MzZTiXg0E4?}_+E8L?0w|Q48JT*odDgTi0MK-@Z}usbJEs3M1%&|2 zn+9JV-J(SRp{hdA7%j#+515nkL?K5Zc?GH|^|V72t)rW!_lHH-T=u6qo35%?Uvus9 z@`|X6HXU*da8<^2;wh{)FRUF$B$J6Xk~` zy|3PwejQke({m7;nvQD-L4v02a$VPTjAjw;p`7J66$B!ww|}$#@_S8L zZ%ib)iG$dEyO|NB2~S0lGKL|9P-)2@0%AO6Y73Ztb6(5x5CqX;o-MIS`=!>hExVGO zkT zPxqbFv_|EIFW&I7m%Z%EH{BFr?j|Y_jS(rA5Mw;!jJrPYfe&07EsaK_e8=lm8&4Ju zl%Y1;;y?vd5Cj^+o$qw#2S4bcyI1Q}bRa2cbOS8b^;f@k^WVMm?Kgkz)_f<7P^gqS zj`N+^T^*9z)3-s4_qQU49|B0Ppg$KocaL-vm}s8nth8)$xQnU^Tmr^suX^QQ{plaQ zcxh?0wpx$Z>Rk4nKd$a7)v{{UiLJXuhyp2%oh-M{O=G-B03@Dw{&^>#a&nC3=Fv<5 ztE0GU5C_I?d+PD`KJU5LfAN3J0TYi<6_4f-aKLhJ?&iMVq=s()Y2*;2DOBukU-#c; zG>2s=2V>@))Z*<4?Y?~A z`6Ab!=1>DrEm=C?)H~ks@fTej4enlwhFvtBDErU<{QUdh|H0k2?s@y$-(@8f_XtF! zW?b%em(x$bfpQfZ*gmvBnr3IwtTROzDga`d6~R-c3vO=a zG5vBI?1KHg@19e;=LVp>$QT61u}hpdpnSpfqJnxj|78i#8tD0;xu$ z^t%vIs}0a9jNr)mflmLb&PAXoJewtNY}JB%Y<^pOvgKIRohg0af;DW#hP2Z?BYxBcIP|iZu0;jW~D4bvh+C9YkHcs)9(9Q(g3aI=BN(rphNCn^|33nn%{m%pI_3aa^YpC#koNiip(p zI9+5?eNX;Lrp-1_Erm)W85Pw~RXJsqqcypu-ED%Tz&)phW|35MZh7N01PzsHPz|ME zT$@Fa^E_TQq^zcCoinf{GjmmW#~hueglW(x7ZKI`f4I1%*CmD50;I`$)7S&jP3vKA{CnObIZ(r`Eb zy`djg&aZbyc>p?P_PLX*0PtuEx#jlTZu;t%U;WzG)D~Slnw3m~!L6z^RQk||J>rZn1_$QBN5%^B7|k_0&|UjxolV;?Zv5c+S3+47&qXM~hZ+@`FKkTRf3V4Ma*} zEESrPyRcSEBULqv7UOtrt*&c#hk4L|oc1NP!T}JP)<_O->ruLw_TkK4FNvoXPN~(6 zHt{){SUndcqzj69&X%VZaWT3X%&Z=Ji~>`*$GWOiD|IpRXdb=PlS%*ZG+hbuFF3dD zZx2zk165GbRE3c1J}So~or`^T9;ONE+E{wX zrMbQ9d8Zg8X9t=~>~ON2YU??3%Vrc3ZcZ9*wsJ#3O%r%_8=;pS&kxg$DzzbXz{Tol z?|%2Y|M}CO&JFS1b7x{zcf>m0=}xCS=5deKGKz#q8!5Ko;l$RL1D}flO-5SX`HrVO z^q~)Nc&I|kVWr7Y1!{(`-TbvTyy1;EfA!|J3=HV!!g9teH>An=m`+sTo$r=hzAv37 z$^8|1n@i|yx^xbeh)0W_W-4%Z!RmPYKVSUMx4-M}mUk@e*?oIm*MNtSEbq|Kj-bot zGS18Q4u}BFQOzNlJ5)5Ns)kcfIpz4{jx#qAF}S%)8mtrU9zXwI|M}#nK6!lG9=K_R z=+WIo0rOD^%Rz&vXmCf2vF$TBF(q&bG0XmTe;WPMhN=Gad8np5eo-?*tBXgs@#?D8 z<7!l`>^kze6HdJ2X?H&J%rnop$K6jl<&Had9l5k)IgA7_Gt1#xI>agf%)PE-DpW1S zm7W7WEpyLpjt#9x1|AJ9A)TkHn$FmRAN=48F1Rlo8gkT|=JbWb-QWA3_kQ#rK5AxR zRMlR;?scy>iz%C&6F73C`J;|H`l(<4lxkE(x6;A8d|8t{Ey{cCFtFWX?pb@0qE1ii zhl`sksw(W-wd?4kk3RY2Q|^5FT~0XhgH^?=dc zphxc7b@E9k-SeJjo_+Q?=bn4-JD+y?QAZvFfvSs`Bc_powGs(vJFpEEPf1gGms|5A zcf-*tXcbaBqRti~XNNphu>zhNALi^!PD8rrJ#Uk-w0AohNFU{HTcxOC(=OCR9lprL zsg#X*X~u%)3Yc0TOqSV|XlQEHCI2w+opV9~@BiIL-COwZm&)X`iE>%(Uemh;sipBt zDprFc1c>-oZu-*O-~RS<&N?T!n!APokr)l?stT2_`?~A?@)ds-G{ji9(XE&0-#g!v zHldUYxH!z4ncN`yc=D`4dk# z{vK!E{n+DgwnI?O)&YmbZNErki%}SskyfISgiI2%|80@X{q);BL~S)wGB0XrahG5SxNsHElj)Rd&>&LBn8T}qGd9cg;xhN$!8L67&2$Hh7Q<-i z+9zB$T3Lz?H?LGYx;a8nhu8J^@80^BTfTO)TNDI~_Qp58`FTI|ypv8m$pLpuwQopW z#1+?Gd-V%{wS@(Rv10Han z``-Va_q@l-jvZQs<&_nW_`(;j|L{jX^!B&?-M{?Xzua`=SJhph7RO;UvKlcZ>5&Fa z`Bz1%o)QV_YKUW~sCxv&bH|x()+v`!xOp*u2`66gfD7(@ z&b`k%`|P{i9e2xmydn?V;}kOM{oMdS5Ui~iHMt} z3QPgS%MZPOX3I;Hvn~iE&Hh)at^|VA3#ST|n@JF{)L&o3eR*m5q&uAOkcU0w0S|i6 zz0Nx8=wpsM>Zl__6{?`OeC^hoZo27{pZU}WKl1+fzyE#z^WXn9-aS^NHkU3cmalv5 zMKoO~Jr_Y}$!9`TJmt0&CIUcJVob+SnhJ%~19&`M4H~ox5$X;tZGU2&23wYV0hK0s zO+Q%ZR!oPBa^UE#4RV za-o$xPERhNntHn%BDe!fOCwPouf@N5^{b!yly5xgxI3texj|fm05E~OJ?xkJF-g!o;e5Gqk^z9DakAa_Mk0EpwYhhF%I|MT=`?Ag7# zTJPDjwz}uG-CB*lc+>S?+jH|j|JP^V{m%D1_R)_yZr71-{kylk{w;4g|Gww{%K!Vn z=bn4-T|19h9xa)vRB61XG{AR!$9vxW^ly6lxE`;r?y=}9Dmq!P+MIELc&FPfnpi(q zhzI~T8!e5jjsVh-CIWFY4Y{1sYtxae?Am$h#TP&A>!1422R`JO(>;x9k?@sCS8nZqKwDPbb|UqpN8W|VuS_oTbL03tG~s^d>M{+cITo!(Es233jC zQoj5PU--gbz2dK|jv6FXVQo$S9* zW#;kqxk8K~;*WmsPv7;f_vYb@dMOVjkZn39NohK-07j$W zX3=U5@t8Xtd->&8TzSQn_dWMMce>N5qoq;G8kT%1B(;KrxucHtr$6~muYUF4{P~~% z`G5bnlWXx4Yiu@sGRcxZ{qis*zNR5u(MVDy-eU z=RNOz@4MgizLfDx!zzq+E?;oJ`#$X{-+0O6FFo~?lfo#dip3OS!_yd0=8t^T!@f0s z^9^76;``qF-aq@3m%itn?^_$MrEgEIA3Zu8s=?BL3@PvrV$m;r#KSJQ-~Cj>%JRx+ zd09i)v2(|fJ9nOZ;>iNlT$hwaXUt<@UvS_1zx2=kv>r!y4@*mu#@w=4$9UwPad0^pk2s%Y;lCv=={W?oKc%XE$bBK#-r7DK-FaP?l?|I+*o^a(= zlDaOcsz{8cBXJSc>dMP6|A&wML+a?3q7H`v0<#<@HyZ+Rn3f@OLI@&y?|Ys7fcxDK zF|~sib9l7TNX=qx^`}1jnOD8;b#;upjyN)eQF97Cm6Z3pujSFQy;nBuWyiV)(UBX! zqN%0qMtAu*{bw})6$&+_E zB;*FoRs#-&Jmfz2z0bMlee`1=uVWO|+~BB`0Osp+dZFpAVVz3b)Yl?s1ti4|X6Ard z3?Zbap$e8Js)8)-+VPa9KKZ-9=X=k-=RGS8NQI*gq)D&~=l&$20$M+JH2LH(-|f>pLK-vZBlPJNJI~J^Q?Gf5x}H_@#gRo4@|sH+|)6?qab9 zM5|!YP?{J)5MvA>h``f~dsRq1+|A;HAM}7<`?X(%q)AY^N7z5`{`X&c>6JI%a*M0z zD4cfJ(|-7efAFbKdeRX)jucNN6qb6;c)oy$n^mF?>VF1h46&wh@Ek%n|;-CY7)DuqEsJ4G}3 zpSzrX#_4yyD}a|M^I^?Tg`$}`CochIVuKo|PmrFl5_gR{yd#Zfy>8W_~Xg_FU z`#G8rCKXtf3_yDeYzhv$g}u$+{&lE~nK<;7^JDQ%&*Lt@(&0%J#OMts*kjFmualib+7mjcFiksxh7SR;= zjx-*JGC53BYb!~jpO*ih|y?ea2CJL|J zEeC(ij`n^AO`pZm!nqyq-_seCSjW@^KmCHjNX^EOP^p}F(kZ|Fo4@t=%O1U?qvqz& zCa~OI$kPaza3J+zRe|D+d))nJfBF|Lz2wU8`TpSl^*eZ;W21hMhn?A8N zt|gSrU@0NSUKy$g=$@RR_ZM1kK5{ALR7=&hPq?-kjdq%b|Ad(Po8I`A)jfMe zVCL>NS_*aacfa#p*I$4A>8GET8cn5EbGiLFj?L%3T}w* zODm%jPdMS1e(sm9xcus$eZeoi_x_r#-){B1g^gEvtRHW4PndN+pYP0h&cg;Skuq(%8J6 z!KP<^e&H|L^k|BM+B|AA36o0=CC~kF0h^ia zZc8oONmJcQxu(6-9*RBqvar~63p@TcfR9u|MB@cM$d7vxB zGwyx%Ii;LD=1Fn{RpD-@akI zHkJ^UN6SG&vp>y=wI*cEJse6?`reW0JZpD2M+@C9lvBr9;BHpuks9;T-HCG59U8(h z#~$~SKk?IVdGlM|{DwEb6dQ`SnXLxk%mP+Bk*kW`+_3 zxu4z!bb9!jv12Phefk!DpjR= zkF(Ey(_7wp$rX=Rsm7%vwVS(HN-JqxbkV%Q7E}4Nq#vRKqowfRhdkufult)TzV2!S zEH5n!0D}S{&8aFHG^m8-m7Sh?EpDLwPiH6ByPZgXneM^^AC%6KYO;;x5T}yk^Y4A` zqc4198U#N@&@fjKtIZ8J-0-EhyydMH%`5`0Avj!@s{g+JzuxlJH{}6!L_+A~9vwS& z9&z=xPf*cl5#nYBL8@a)gTkkj+DwcgfRwq_EKRB&!WSQS*$s7vFRrUgicEp5;4J-0ie zh?g19(tHil8Q#oGSx7n=D!?dp6t04XOf^*1NQ0<^HYLyerOmh@?mY(mTU1k`&?!uv zc@dRVi!|f?ZV}6Zm@KHd)-?b1UYgJ^>1`M328cOI9U5A}+kZ<@UMi~T4L>>kmGfUX zV4BVDXVE=RPLuyMS%glko<3cLk!nyCgmA-+H@xpdAMhY~sFvI#4Js1rdTBIz%wrx6 zz`P8=W1idLOh@}wFDtlANt^jKlSNbCk1dDOHZ^HHe2LO+$z00dF8N z+Gr{4I%3CVmtA$;6<1p{xTNC4l%o>>W`LW=dOTj;vs$l>KlGsw+|wh3I^)m+?RdFCuBZIgy}WWEjE zOJ){v^;cJ-+VX=c*#S{;w`;Gx?ueCLY1B?lovl+mUa1Fv+uPsvr5nElMQStVfYq@I zVQp>g6|Z>3+S(etsRqe&Xr-Un)mL1#Yv&Oq^#ee+hmVxPj*{ifrTD4&oR)6Xp;D;3 z`)E`>>B&#|%a{K7iANtB5Q2BQZj^y-yp42`29!0oXf@!YWrf%{u7xGN*I8%$`ftAQ z9%r8glROeF9O|kLF=#4(flDm2$60%r^AA)~Qmm?~u4`36!19izok#8bjbH!GpZ~d^ z-L+$>9$W75lSZOK+CRYEqNys(-7Kgg_mwq>hf$uV7;2&Noh#LkKJN!!^r9D@e9DO$ zDx_IRq{-Z-k$Hogi=?^F++q!L_ZmpMV-noO40+bx(T;G$(JObm+v&gco42M-b|Fv(-GBt2ox!mSpjdCOuuVSH$HPW*xGpQ&42e+xVQvMQFxhVDphD-_=tz?+Id7O;zb&m zqM38P*&dWKKBT*#OBF7^^zxScjg^a3Ekf0Dnn#TYF zgfMbdR8r>jY}QoJMQh}Vg;G&>D#BIKN`r{r?W{9@@&!L5%cH6qm0P)_K78gF zM>8OrN3+prWD>9c-!C9{l+b+0%)38{>0ufyjcAgiT5k_n+7wX@>H-tjpfN^Ryyv~n zdF3l!aoVXT#jyoReIAgejdLJ1zlI1*nxNe+mqnVTaL_fRVpIh zQW@qDM31j})nB`Now|gorpcQH5QGnW;KLvN=qE(f6>D*v#%6SfstEi(XPoq|O44!3r#|yM(37Z2(4eM>7HvEpjjGEoyYvtK->)CJGScXpc0$X4WfF_h zgvC?Un;Ak?g;7;|l%Qqo83^Re)9?0czx>M&ec%I^bV&g7+EE+KycTiQ)Hx`ky{@Jt zR}ORfBkFEdRYfy}NLBs8@Bi^Pf8#d>SFdBGBA(_QGj})lR7q)uXkreCAXl%ci$cWU zB8aipAgT!B;9aFto8W1-PES%2scx^3JbikWF&ndfya?ST#1)OFJ^V0gOlWlcLDWp{T z49o{MQRcRx-1GnpLG!*WvwxJdRWx0s2JW!aQhUYTE9K+Zq(nosz7pm(-5gW=;Mzu` zDCv2kv?RaNQ~*pAjV@yUCG@##_I!)R(1ecdZ@0bdc5*iPHf*V}f}|NM{k`vd-@ktL zvuJ`FMRV&C;LJ1calZ@hTa5zTQp5@fw0Af~nj)qY)%hJyz0)HeaiNV(M5;IV{n>x{tec6a!4^^G`D)8$o&pG7C?+?(46(@+!oBI&IeAh;`jPhWivgNRyy^^7Y=g4}Gf5W|tw=ffC(2 z#9i(3D=)wM8FzEf)ktYBDk~jJKK;p0zyAXtgr{EVVzCY(4b=tFu(r1PhBv%n9BYVa z$QeZ~T|PoJy5h>qBxRU$uirGfvA6ANn#MP6%ehn&<0=2rhp0^4Lc_?iYUg7w>TVN#<6jadws9 z5VPD;5#3h8()>7)8Y`uv6fKH)HL8B+|NY)|*Iebbi&;=Hk7=HP+Uh)Iw0m5QYrEsx z?)s)L-}=StZ~W5#-gN6%Z(G~FX5$q6iwi=f=4KYnVyFV(aJ}O4%m2UUKVMWs7(vq9 z7TQb~s4DI;k9;M9Je-<0<0dte0*gRZzWymsz4&pDPjer`a$jUgXD*;BX@>XCb#K;>XWuzye*tX`KJ|2+Y+GtkCxVH)-98egmBZUq zf&F?3LQ08un(8DE=-BiZ?S!fM2Iu|!ZMlZ$;hysazx$a^n(QafO)u7HRzIUKvNVB_ z^1L(jgTozK!kjj8DZ}*S#nSGN>w3Hv)9pg5rLWv{<2&B*&NI(CGlegy(SSMN=8Bz1 z?Rwb5ANJl4ynk)YV~kP)!PI3CK)KWBZ!z~#6&~{7hurawcl6Q}!D3um3MmhwmA>_z z@A%3sUm1Iv#9niBCEd0-UdH-MyO#TQq=MDbNnq$WN$LXP&3X3bPxsiCZVlcqwa&|x zvB}{QqmAo&&v=}JY!ydmR$Ubr157nF^cO(edYE=NT5+HXRqn))f~#D#Xd+k&OHY0B z6IYfhv9yEYqK@dmIGRUaU0b{9rW@-z-gfgXr=Nb8+icI9-}cV&xZZW-5orh@&&dG& zWa_13fFsQxTwe8d^d>-1LL2}ug`dCoIcLULS0UsAVngnMY3{i-H=?`M zzBZ1x-g@hv-M6oh?apdT?DA~%4@Iq)nEVR z8*aEEj?LUs|7?rB#GET~a;{v>qDYeh93kB8%rjr~qF+1a4##@>iCOw}nTjJddP&_! z#pBq_{I=bzH+=DnyKld3b@%FtC!TQXsi%aIhDx;>!AydBG(bJbeeZvPNgBQ=jrEyF zDlOe_awnXU3-A*C=jLL$TaS6D!gHSeo!{`JuZyeWk*JGDt3wEmG_|kSh-Ucm7r*k3 zx4-KxZ+Yt%zW6_1{NEeCdh=J0I%4PX#~gd_^Ui<7!yfUNM?dP=;k zA(!!6KG%Nxo;7#->WyFBvwQauJ9q9_-mz=fjx;%1RY6etIxF%@!)QFNN7V?5YI*sY z-}-H@e$}h~_s0L#5ZpDIiH5v9WNa3J;ApQs`2r`_G{4MUxN`MuYUAW0+L^Z1#UW~L zhFofs+I>M%E6<6a&87!qjPACmsn}!2?X{>d8T~iw*=tN3w0`<&5@xO(?UZ_#NGG*H z?yag96HR@MTg-3gQ6`%>YABM}Z|QW-7EWwLHye`M;N~Gjy0K7uSlVDu1i*N0%`K)T z3F0vGm;c3IKI7@%vbX`8 z>Q`=BU0YMtQ%^nhu6Mo5dFS8fipwrP=U!)xMoR#yQ806<1i4jo+r7NmoHWyL*-qU+ z38Iv$iL}#$If3~fNB0wX_?J}7Dk6}OI%Gq|Jc{~*AMl`uT<`#aS3&d8oS};5L4v#A zdfVDc9z1$>TC5+?;f8e=io_Tj^T8D+yzFf>asHTKJuIn#- z=__w~)0^M?<~M%gAOG=xzx2O*_Uu;C<>lpj-s2t*eb|E^_xOu0eE36`mq$`d%x(Dq zDY^4By+f&G^}J(_qkGynVZVb#`h=FQi= z>FhPxz)zSCk{te&V#3aA!^>7P-dogA9M!#+F}WOW=9PU9zFngK(jC1N`MX0peN?=2 zXP-z_UgEwKtBI#(o}Co96;fie&uwWf^6#|WxLgBLZog$fi018fN+m~%;5CH_Ue~pT zrB8nL)9?NJ4?Oabk5EsO35dB1q!I~1?|Ry4mtJzwpS|Rzt7~1 z0S_U#yTa~s-hCeUzz0bHkOqZJmC}})pnl?$|MbyMeWEsV4KNc|&Ev?oXRBWh;SiXc zh%1oi$nkawq+X-tgQ;;k-13Y+-Lm9T1C&bUb5W&~XYCO$Yt3VBo5gYWO=(t{hWM!s z2$0YQ*xvot921rMx#S|()HeVk?&=aFT&)^a(R}yqdtULXSN+Cs{J($s?Zc=K7+T-XsG;2rt>vgxz0f3akeSzHJgcF|i%x5V? zLXh0#y^K9yx>rQ2Z}{?8U--fo{>95)e!~r4M5#hhRg3Y7|$d#83Uy>32RgHTaLUr3Pk>R7uh%P2{E+X5xUhx%_gzfXQEEj@mkjr+vAfnDQCJ zgz_=Z@jh~7+f5Y`Yf}|LNO?jyR7clbb?uIo9WmAlkvuAHn(xBR{I#!n-Dm&(-&BJH z(R8j;xvQeCV`>(@y1M5jFL~M3*Ic`@qY^2BbQ-F%k|_4nr#$HmfBV`sj~Yf0wc492 zU#=gM_I|Abvs$X4L2c}n>iOrM_r$BN*0eyXzbYMk)zu?vvzu;UgdZ=s*17AHVn!k9gQm{rFGa`LQLPEakgV+Yv_{^)o;H(?=e$12MuqBC2YPwIHYli@5n~xBl3V|JX}k z^77kn-7Q*qZfheD52KNzR@HGm{=)TN{HdS#@i)EU4KMuF7vB4`y=WiBGw0_bNnlOBro{_?{-n6=~bbc}`zm%9cWI?Tw-H5J}hA z+?-lN_1MQe<|Qxrv$3tIXj7u&;%<1$Ti*8Zk6*K6d1YyNWWYGa)z#HI-Rabqzx2;f zyu*p5J)!2}d^ijrc<=ka?+2c{vU7(;vnbI*G>6qetd4bE*SFnvd!AV_mN963s-@f} zIOuoK!`udfn9?Dpxnxd#wmj1rO{VuTtH-o**lMzWGvDst$Jg^m;qN zWj7IbI|-CgO`3d%cRqB0P5z{se!bm`wdcamwiI(lxp#_?-7Tgm?4=r2CfKvK`}J>n zV{I|rNm5rbxVRyXePwy)#TQ+=JlY`|0CBT4z`H;+l!iT_f~v)+s+V4N>4_(uAR^J- z+|pq14%Btjps#t&Yj@v%8%j;RptNFdx4d&|()LK6E3_FY$h*a#?MwMlDk7a2*}6S8 zrI^0yx-gOLNROL}pfVO`8E>)a?pj)1$KFQtSCmW3%brPTgWpTg3aGmTU8>!8ukCs6 z_y54Rf9o^;@gpA|kH_XNs%a?H7`={hJT^}gPI-zIt!*JSQGC-$w*#d*QjUg{SlJO& zL-qf&_vQhX71jOt=bXCl_4EwGuC@zS&;{u3A0ddDA8n>vyENWt+(JWt=#3bME z_m{-PEm2Xwi93t9fyf9biy{$31ljj}nSt55`@LJ|{Qfvqb?e@@^y{APnI3qD$Mjq7 zt*Tpfs_Ju2oyEQG^{;>2V;)P*#Q1a?8y7IWLx?#1kQ1S_di_4*-D+A_lXz3RiYIq}X%!JzjFm(NJjcNd*X&&5DU;I-T!) z?|U29Z?sHRDr$2e`8+F76fFfVy6EEnx%d~cCjc`&cVy;v&p+bG$36b>MNxz(GQn^? zJLRe!QxaR!X0Mi8`Q3l=p6yp|Z}r*PK**@o1u031yYF1{r+@n15C5;fz3KXEM1-9K z%&uLmN6h;Ffi>%Xc=`{Y|NJ9Q`QG=G&Cpys-5D>XSE3e7tyf=r-EXeA^2*=- z_L{4%y79WJZ@c;CR;!3IV3_-MSVYRx8)qI|yYAMTZ@>NaJMXyr?tAXL`@S`I-@o?W zd)M5z=7F^ju3zU`h2@DOM^%pXF$8$gjLl8~|0+m89wfCAG-}*>?3VKW ziCXZ08ZB&vdhuO<=9xdb_n!MGiavHs?@NV1y*%yEr#<>n2b#@xzy%6`MZ|V5v0TCh zwU$jj^O?^SXxDp%z*#&a8Ott(R4^J)MQ zz4R&s98}#@_vYyB{y3<1Sq>~U^k+p}!!qeIi~K?yqJLB!+^7nG!XgVre|4%hTLa)! zwAx#i40>qDaIk%A^ zB6fvi$IcboZMVbgU;74jEpRaMP)U(Ppnz9@;m%Dbti!hq&E65w4WnD&qvD~L^ZO1 zm8 zyP$%Y!9?n%YFd;miA2Btb;)Tf641Z-2|lr+jN_Y7^M8 zBk#@Zp1QuAnwb$O%q6&0bUm$Kzv&%sd)xQF`vWF|h((wW?yc1doka8`!l#8f4kUq@ zyf3%kamTm3-usY|gODP&ippMl?6Lp8`v>zb6;UCED_B*Szy(9advERp3OL5}^mO1CV{6XC zF^FONU=)U~!Uw^G*^+mgNFQy+!kXNic4OLBDc4C2p&S{d_!k3ijK6WODyClRJg6_I z+nfT&w9QdnBir*sq6Bq=NqA-S9pFGiA=szHN`y`OQWX%PM+6zjg!5V|b2)o7%AtNu zRLL4l5%kmx&aMtWSWY4ku?xwRJ?N&JZ~nzazfjVEMMPnEm7w15y7SJ@IqWck#gwpU z)rX;i$i& zfe)^Ga6JL8RUr7vRtGV$Ex`yaHn`bjt+ig$s8ZZmO?vk**8nATEq=?J z-#ocu83%{$AbSReCzS9X{^*B)dcnC;cAe$1BXHrQEW6#(VozD|Ww%tX3?eEvuG{#o zcmBa;m;TCe!Q`~5ZOAxUqY$Qv>x_TRnL&$!UvSh>2S4G7;>9s+Eh@bboGKB!6Tfic z|NGikL=>c4aJSnPUlQQhcnn3$sYuxs6_j03PeoC51?T+aoKwI3Em&aQP^W@m;k*ue z)-#{-)I*u6D4a#Gifo5!hJ~T;SxZd>R&+YuKYQPw-E+@fQqC}=J<;~w&QB;rJkt;x zmy{iobFHaOGynVl{NGOLMU^919y;mo$5&Z_*F(eXaux`#1s7&5VM#-0WnleX2pLdSeYi zNJvn0Yc7rns#aL9CND*U4(d0FYtS>0m?4la3MR*8w{ymsKP
7ztcAtDOlf?S(_ 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�Kart<1xi22+$Z`b_X^eY#B%=r^@ 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 N002ovPDHLkV1fx~(@FpU literal 0 HcmV?d00001 diff --git a/src/main/resources/icons/agents/auggie.png b/src/main/resources/icons/agents/auggie.png new file mode 100644 index 0000000000000000000000000000000000000000..da8d4f13c400da66e41a1dd456cea06e4c122400 GIT binary patch literal 3308 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 + + + + \ 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 )