From e7a8bc07eada33a108dbcb936e6d8a75bf450cfc Mon Sep 17 00:00:00 2001 From: Vladimir Krivosheev Date: Tue, 30 Jun 2026 14:30:37 +0200 Subject: [PATCH] IJPL-248623 Move Claude command handling to provider Claude slash menu handling was exposed from the common module, which made shared prompt and chat code depend on one provider's command model. Add provider-neutral command metadata to sessions-core and let the Claude descriptor own built-in commands plus .claude command and skill discovery. Prompt completion and initial-message dispatch now ask the selected descriptor, so provider-specific command behavior stays with the provider implementation. GitOrigin-RevId: 84dc9d6172e3c0c584de5d7ae9a243b938b87e84 --- .../intellij.agent.workbench.chat.xml | 2 - .../src/AgentChatInitialMessageDispatcher.kt | 2 +- .../chat/src/AgentChatProviderBehavior.kt | 7 - .../AgentChatFileEditorLifecycleTest.kt | 43 +- .../AgentChatInitialMessageDispatcherTest.kt | 32 +- .../lib-agent/common/api-dump.txt | 16 - .../common/src/session/ClaudeMenuCommands.kt | 42 -- .../common/testSrc/ClaudeMenuCommandsTest.kt | 18 - .../ClaudeAgentSessionProviderDescriptor.kt | 12 +- .../claude/sessions/src/ClaudeMenuCommands.kt | 202 ++++++++++ ...laudeAgentSessionProviderDescriptorTest.kt | 129 ++++++ .../lib-agent/sessions-core/api-dump.txt | 36 ++ .../AgentSessionProviderDescriptor.kt | 49 ++- ...gentPromptClaudeSlashCompletionProvider.kt | 371 ------------------ .../AgentPromptCommandCompletionProvider.kt | 184 +++++++++ .../prompt/ui/src/AgentPromptPalettePopup.kt | 8 +- .../AgentPromptPaletteSessionController.kt | 12 +- ...entPromptCommandCompletionProviderTest.kt} | 298 ++++++-------- .../testSrc/AgentPromptEnterHandlersTest.kt | 95 +++-- .../AgentPromptPalettePopupServiceTest.kt | 2 +- .../AgentPromptPaletteSubmitControllerTest.kt | 12 +- .../spec/actions/global-prompt-entry.spec.md | 2 +- .../global-prompt-reusable-sources.spec.md | 6 +- 23 files changed, 880 insertions(+), 700 deletions(-) delete mode 100644 plugins/agent-workbench/lib-agent/common/src/session/ClaudeMenuCommands.kt delete mode 100644 plugins/agent-workbench/lib-agent/common/testSrc/ClaudeMenuCommandsTest.kt create mode 100644 plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeMenuCommands.kt delete mode 100644 plugins/agent-workbench/prompt/ui/src/AgentPromptClaudeSlashCompletionProvider.kt create mode 100644 plugins/agent-workbench/prompt/ui/src/AgentPromptCommandCompletionProvider.kt rename plugins/agent-workbench/prompt/ui/testSrc/{AgentPromptClaudeSlashCompletionProviderTest.kt => AgentPromptCommandCompletionProviderTest.kt} (51%) diff --git a/plugins/agent-workbench/chat/resources/intellij.agent.workbench.chat.xml b/plugins/agent-workbench/chat/resources/intellij.agent.workbench.chat.xml index a62ca75e9e3a..c7eb2b2cb4cc 100644 --- a/plugins/agent-workbench/chat/resources/intellij.agent.workbench.chat.xml +++ b/plugins/agent-workbench/chat/resources/intellij.agent.workbench.chat.xml @@ -59,8 +59,6 @@ - diff --git a/plugins/agent-workbench/chat/src/AgentChatInitialMessageDispatcher.kt b/plugins/agent-workbench/chat/src/AgentChatInitialMessageDispatcher.kt index 12f38dff9336..9210307f1bfc 100644 --- a/plugins/agent-workbench/chat/src/AgentChatInitialMessageDispatcher.kt +++ b/plugins/agent-workbench/chat/src/AgentChatInitialMessageDispatcher.kt @@ -328,7 +328,7 @@ internal class AgentChatInitialMessageDispatcher( tab.sendInitialMessageText( dispatch.message, shouldExecute = true, - useBracketedPasteMode = behavior.shouldUseBracketedPasteMode(dispatch.message), + useBracketedPasteMode = behavior.shouldUseBracketedPasteMode(dispatch.message) && descriptor?.isMenuCommandPrompt(dispatch.message) != true, ) true } diff --git a/plugins/agent-workbench/chat/src/AgentChatProviderBehavior.kt b/plugins/agent-workbench/chat/src/AgentChatProviderBehavior.kt index 63911b2e5398..dac8897ba19a 100644 --- a/plugins/agent-workbench/chat/src/AgentChatProviderBehavior.kt +++ b/plugins/agent-workbench/chat/src/AgentChatProviderBehavior.kt @@ -4,7 +4,6 @@ package com.intellij.agent.workbench.chat import com.intellij.platform.ai.agent.core.AgentThreadActivity import com.intellij.platform.ai.agent.core.extensions.SnapshotExtensionPointCache import com.intellij.platform.ai.agent.core.session.AgentSessionProvider -import com.intellij.platform.ai.agent.common.session.isClaudeMenuCommandPrompt import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessageDispatchAction import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessageMode import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor @@ -168,9 +167,3 @@ interface AgentChatProviderBehavior { } private object DefaultAgentChatProviderBehavior : AgentChatProviderBehavior - -internal class ClaudeAgentChatProviderBehavior : AgentChatProviderBehavior { - override fun shouldUseBracketedPasteMode(text: String): Boolean { - return !text.isClaudeMenuCommandPrompt() - } -} diff --git a/plugins/agent-workbench/chat/testSrc/AgentChatFileEditorLifecycleTest.kt b/plugins/agent-workbench/chat/testSrc/AgentChatFileEditorLifecycleTest.kt index c4e6e34fc019..e51837bff812 100644 --- a/plugins/agent-workbench/chat/testSrc/AgentChatFileEditorLifecycleTest.kt +++ b/plugins/agent-workbench/chat/testSrc/AgentChatFileEditorLifecycleTest.kt @@ -7,7 +7,6 @@ import com.intellij.platform.ai.agent.core.buildAgentThreadIdentity import com.intellij.platform.ai.agent.core.session.AgentSessionLaunchMode import com.intellij.platform.ai.agent.core.session.AgentSessionProvider import com.intellij.platform.ai.agent.core.session.AgentSessionThread -import com.intellij.platform.ai.agent.common.session.isClaudeMenuCommandPrompt import com.intellij.agent.workbench.prompt.core.AgentPromptContextItem import com.intellij.agent.workbench.prompt.core.AgentPromptInitialMessageRequest import com.intellij.platform.ai.agent.sessions.core.AgentSessionThreadRebindPolicy @@ -22,6 +21,7 @@ import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessag import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialPromptDeliveryChannel import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialPromptDeliveryStatus import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialPromptRecord +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionArchivedSource import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviders @@ -2227,7 +2227,7 @@ private fun testEditor( pendingScopedRefreshRetryIntervalMs: Long = AgentSessionThreadRebindPolicy.PENDING_THREAD_REFRESH_RETRY_INTERVAL_MS, editorCoroutineScope: CoroutineScope? = unconfinedTestScope(), showComponent: Boolean = true, - providerDescriptorResolver: (AgentSessionProvider) -> AgentSessionProviderDescriptor? = { null }, + providerDescriptorResolver: (AgentSessionProvider) -> AgentSessionProviderDescriptor? = ::testAgentSessionProviderDescriptor, customContentProviderResolver: (AgentChatContentContext) -> AgentChatCustomContentProvider? = { null }, behaviorResolver: (AgentSessionProvider?) -> AgentChatProviderBehavior = ::testAgentChatProviderBehavior, ): AgentChatFileEditor { @@ -2254,7 +2254,6 @@ private fun testEditor( private fun testAgentChatProviderBehavior(provider: AgentSessionProvider?): AgentChatProviderBehavior { return when (provider) { AgentSessionProvider.from("codex") -> TestCodexAgentChatProviderBehavior - AgentSessionProvider.from("claude") -> TestClaudeAgentChatProviderBehavior AgentSessionProvider.from("junie") -> TestJunieAgentChatProviderBehavior else -> TestDefaultAgentChatProviderBehavior } @@ -2262,12 +2261,6 @@ private fun testAgentChatProviderBehavior(provider: AgentSessionProvider?): Agen private object TestDefaultAgentChatProviderBehavior : AgentChatProviderBehavior -private object TestClaudeAgentChatProviderBehavior : AgentChatProviderBehavior { - override fun shouldUseBracketedPasteMode(text: String): Boolean { - return !text.isClaudeMenuCommandPrompt() - } -} - private object TestJunieAgentChatProviderBehavior : AgentChatProviderBehavior { override suspend fun beforeInitialMessageSend( file: AgentChatBehaviorFile, @@ -2455,6 +2448,38 @@ private data class ClosedTerminalSession( @JvmField val threadId: String, ) +private fun testAgentSessionProviderDescriptor(provider: AgentSessionProvider): AgentSessionProviderDescriptor? { + return when (provider) { + AgentSessionProvider.from("claude") -> TestClaudeAgentSessionProviderDescriptor + else -> null + } +} + +private object TestClaudeAgentSessionProviderDescriptor : AgentSessionProviderDescriptor { + override val provider: AgentSessionProvider = AgentSessionProvider.from("claude") + override val displayNameKey: String = "provider.claude" + override val newSessionLabelKey: String = displayNameKey + override val icon: Icon = EmptyIcon.ICON_0 + override val sessionSource: AgentSessionSource + get() = error("Not required for this test") + override val cliMissingMessageKey: String = displayNameKey + override val menuCommands: List = listOf(AgentSessionMenuCommand("/mcp")) + + override suspend fun isCliAvailable(): Boolean = true + + override suspend fun buildResumeLaunchSpec(sessionId: String): AgentSessionTerminalLaunchSpec { + return AgentSessionTerminalLaunchSpec(command = emptyList()) + } + + override suspend fun buildNewSessionLaunchSpec(mode: AgentSessionLaunchMode): AgentSessionTerminalLaunchSpec { + return AgentSessionTerminalLaunchSpec(command = emptyList()) + } + + override fun buildInitialMessagePlan(request: AgentPromptInitialMessageRequest): AgentInitialMessagePlan { + return AgentInitialMessagePlan.EMPTY + } +} + private class ArchivedThreadsProviderDescriptor( override val provider: AgentSessionProvider, private val archivedThreads: List, diff --git a/plugins/agent-workbench/chat/testSrc/AgentChatInitialMessageDispatcherTest.kt b/plugins/agent-workbench/chat/testSrc/AgentChatInitialMessageDispatcherTest.kt index caede5c1dafb..b9deac7926ea 100644 --- a/plugins/agent-workbench/chat/testSrc/AgentChatInitialMessageDispatcherTest.kt +++ b/plugins/agent-workbench/chat/testSrc/AgentChatInitialMessageDispatcherTest.kt @@ -14,6 +14,7 @@ import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessag import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialPromptDeliveryChannel import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialPromptDeliveryStatus import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialPromptRecord +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionTerminalLaunchSpec @@ -45,6 +46,25 @@ import kotlin.time.Duration.Companion.milliseconds @TestApplication @Timeout(value = 2, unit = TimeUnit.MINUTES) class AgentChatInitialMessageDispatcherTest { + @Test + fun menuCommandInitialMessageUsesTypedInputInsteadOfBracketedPaste(): Unit = timeoutRunBlocking { + val provider = AgentSessionProvider.from("codex") + val file = createFile( + steps = listOf(AgentInitialMessageDispatchStep(text = "/mcp")), + provider = provider, + ) + val tab = FakeTerminalTab(coroutineScope = this) + val descriptor = RecordingProviderDispatchDescriptor( + menuCommands = listOf(AgentSessionMenuCommand("/mcp")), + ) + + createDispatcher(file, provider = provider, descriptor = descriptor).schedule(tab) + + waitForCondition { file.initialMessageSent } + assertThat(tab.sentInitialMessageTexts) + .containsExactly(SentInitialMessageText("/mcp", shouldExecute = true, useBracketedPasteMode = false)) + } + @Test fun preSendRetriesDoNotConsumePostSendConfirmationBudget(): Unit = timeoutRunBlocking { val behavior = DelayedObservedDispatchBehavior(preSendRetryCount = 6) @@ -438,6 +458,7 @@ private class FakeTerminalTab( override val keyEventsFlow: Flow = emptyFlow() override val terminalView: TerminalView? = null val events: MutableList = mutableListOf() + val sentInitialMessageTexts: MutableList = mutableListOf() private val observations = ArrayDeque(outputObservations) override suspend fun captureOutputCheckpoint(): AgentChatTerminalOutputCheckpoint { @@ -482,11 +503,20 @@ private class FakeTerminalTab( shouldExecute: Boolean, useBracketedPasteMode: Boolean, ) { + sentInitialMessageTexts += SentInitialMessageText(text, shouldExecute, useBracketedPasteMode) sendText(text, shouldExecute, useBracketedPasteMode) } } -private class RecordingProviderDispatchDescriptor : AgentSessionProviderDescriptor { +private data class SentInitialMessageText( + @JvmField val text: String, + @JvmField val shouldExecute: Boolean, + @JvmField val useBracketedPasteMode: Boolean, +) + +private class RecordingProviderDispatchDescriptor( + override val menuCommands: List = emptyList(), +) : AgentSessionProviderDescriptor { override val provider: AgentSessionProvider = AgentSessionProvider.from("codex") override val displayNameKey: String = "test.provider.codex" override val newSessionLabelKey: String = "test.new.codex" diff --git a/plugins/agent-workbench/lib-agent/common/api-dump.txt b/plugins/agent-workbench/lib-agent/common/api-dump.txt index fb6b60846fe0..d47430a969ac 100644 --- a/plugins/agent-workbench/lib-agent/common/api-dump.txt +++ b/plugins/agent-workbench/lib-agent/common/api-dump.txt @@ -34,19 +34,3 @@ f:com.intellij.platform.ai.agent.common.icons.AgentWorkbenchCommonIcons - sf:OpencodeGray:javax.swing.Icon - sf:PI:javax.swing.Icon - ():V -f:com.intellij.platform.ai.agent.common.session.ClaudeMenuCommand -- (java.lang.String,java.lang.String):V -- b:(java.lang.String,java.lang.String,I,kotlin.jvm.internal.DefaultConstructorMarker):V -- f:component1():java.lang.String -- f:component2():java.lang.String -- f:copy(java.lang.String,java.lang.String):com.intellij.platform.ai.agent.common.session.ClaudeMenuCommand -- bs:copy$default(com.intellij.platform.ai.agent.common.session.ClaudeMenuCommand,java.lang.String,java.lang.String,I,java.lang.Object):com.intellij.platform.ai.agent.common.session.ClaudeMenuCommand -- equals(java.lang.Object):Z -- f:getArgumentHint():java.lang.String -- f:getCommand():java.lang.String -- hashCode():I -f:com.intellij.platform.ai.agent.common.session.ClaudeMenuCommandsKt -- sf:claudeMenuCommandEntries():java.util.List -- sf:claudeMenuCommands():java.util.List -- sf:isClaudeMenuCommandPrompt(java.lang.String):Z -- sf:leadingSlashCommandToken(java.lang.String):java.lang.String diff --git a/plugins/agent-workbench/lib-agent/common/src/session/ClaudeMenuCommands.kt b/plugins/agent-workbench/lib-agent/common/src/session/ClaudeMenuCommands.kt deleted file mode 100644 index 368b9b8a7c65..000000000000 --- a/plugins/agent-workbench/lib-agent/common/src/session/ClaudeMenuCommands.kt +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.intellij.platform.ai.agent.common.session - -private val CLAUDE_MENU_COMMANDS: List = listOf( - ClaudeMenuCommand("/agents"), - ClaudeMenuCommand("/clear"), - ClaudeMenuCommand("/compact", "[instructions]"), - ClaudeMenuCommand("/config"), - ClaudeMenuCommand("/doctor"), - ClaudeMenuCommand("/init"), - ClaudeMenuCommand("/login"), - ClaudeMenuCommand("/logout"), - ClaudeMenuCommand("/mcp"), - ClaudeMenuCommand("/memory"), - ClaudeMenuCommand("/model", "[model]"), - ClaudeMenuCommand("/rename", "[title]"), - ClaudeMenuCommand("/resume", "[session]"), - ClaudeMenuCommand("/sandbox"), - ClaudeMenuCommand("/status"), -) - -fun claudeMenuCommands(): List = CLAUDE_MENU_COMMANDS.map(ClaudeMenuCommand::command) - -fun claudeMenuCommandEntries(): List = CLAUDE_MENU_COMMANDS - -data class ClaudeMenuCommand( - val command: String, - val argumentHint: String = "", -) - -fun String.leadingSlashCommandToken(): String? { - val normalized = trimStart() - if (!normalized.startsWith('/')) { - return null - } - return normalized.takeWhile { char -> !char.isWhitespace() } -} - -fun String.isClaudeMenuCommandPrompt(): Boolean { - val token = leadingSlashCommandToken() ?: return false - return token in claudeMenuCommands() -} diff --git a/plugins/agent-workbench/lib-agent/common/testSrc/ClaudeMenuCommandsTest.kt b/plugins/agent-workbench/lib-agent/common/testSrc/ClaudeMenuCommandsTest.kt deleted file mode 100644 index c4c9d82d8775..000000000000 --- a/plugins/agent-workbench/lib-agent/common/testSrc/ClaudeMenuCommandsTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.intellij.platform.ai.agent.common - -import com.intellij.platform.ai.agent.common.session.claudeMenuCommands -import com.intellij.platform.ai.agent.common.session.isClaudeMenuCommandPrompt -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.Timeout -import java.util.concurrent.TimeUnit - -@Timeout(value = 2, unit = TimeUnit.MINUTES) -class ClaudeMenuCommandsTest { - @Test - fun renameIsRecognizedAsMenuCommand() { - assertThat(claudeMenuCommands()).contains("/rename") - assertThat("/rename Archived thread".isClaudeMenuCommandPrompt()).isTrue() - } -} diff --git a/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeAgentSessionProviderDescriptor.kt b/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeAgentSessionProviderDescriptor.kt index 397f85a2a916..c84749aef65a 100644 --- a/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeAgentSessionProviderDescriptor.kt +++ b/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeAgentSessionProviderDescriptor.kt @@ -3,7 +3,6 @@ package com.intellij.platform.ai.agent.claude.sessions import com.intellij.platform.ai.agent.common.icons.AgentWorkbenchCommonIcons import com.intellij.platform.ai.agent.core.session.AgentSessionLaunchMode -import com.intellij.platform.ai.agent.common.session.isClaudeMenuCommandPrompt import com.intellij.agent.workbench.prompt.core.AgentPromptGenerationModel import com.intellij.agent.workbench.prompt.core.AgentPromptGenerationModelGroup import com.intellij.agent.workbench.prompt.core.AgentPromptGenerationSettings @@ -16,6 +15,8 @@ import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessag import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessagePlan import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessageStartupPolicy import com.intellij.platform.ai.agent.sessions.core.providers.AgentPromptProviderOption +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionTerminalLaunchSpec @@ -155,8 +156,11 @@ internal class ClaudeAgentSessionProviderDescriptor( return CLAUDE_CODE_GENERATION_MODELS } - override fun shouldStripContextForPrompt(prompt: String): Boolean { - return prompt.isClaudeMenuCommandPrompt() + override val menuCommands: List + get() = CLAUDE_MENU_COMMANDS + + override fun collectPromptCommandCompletionEntries(projectPaths: Iterable): List { + return collectClaudePromptCommandCompletionEntries(projectPaths) } override fun applyGenerationSettings( @@ -187,7 +191,7 @@ internal class ClaudeAgentSessionProviderDescriptor( } override fun buildInitialMessagePlan(request: AgentPromptInitialMessageRequest): AgentInitialMessagePlan { - if (request.prompt.isClaudeMenuCommandPrompt()) { + if (isMenuCommandPrompt(request.prompt)) { return AgentInitialMessagePlan( message = request.prompt.trim(), startupPolicy = AgentInitialMessageStartupPolicy.POST_START_ONLY, diff --git a/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeMenuCommands.kt b/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeMenuCommands.kt new file mode 100644 index 000000000000..08ebad6a0a0a --- /dev/null +++ b/plugins/agent-workbench/lib-agent/providers/claude/sessions/src/ClaudeMenuCommands.kt @@ -0,0 +1,202 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.ai.agent.claude.sessions + +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path + +private const val CLAUDE_DIRECTORY = ".claude" +private const val COMMANDS_DIRECTORY = "commands" +private const val SKILLS_DIRECTORY = "skills" +private const val SKILL_FILE_NAME = "SKILL.md" + +internal val CLAUDE_MENU_COMMANDS: List = listOf( + AgentSessionMenuCommand("/agents"), + AgentSessionMenuCommand("/clear"), + AgentSessionMenuCommand("/compact", "[instructions]"), + AgentSessionMenuCommand("/config"), + AgentSessionMenuCommand("/doctor"), + AgentSessionMenuCommand("/init"), + AgentSessionMenuCommand("/login"), + AgentSessionMenuCommand("/logout"), + AgentSessionMenuCommand("/mcp"), + AgentSessionMenuCommand("/memory"), + AgentSessionMenuCommand("/model", "[model]"), + AgentSessionMenuCommand("/rename", "[title]"), + AgentSessionMenuCommand("/resume", "[session]"), + AgentSessionMenuCommand("/sandbox"), + AgentSessionMenuCommand("/status"), +) + +internal fun collectClaudePromptCommandCompletionEntries(workingProjectPaths: Iterable): List { + val entriesByKey = LinkedHashMap() + + collectClaudeMenuEntries().forEach { entry -> + entriesByKey.putIfAbsent(ClaudePromptCommandCompletionKey(entry.kind, entry.command), entry) + } + + workingProjectPaths + .asSequence() + .mapNotNull(::parseSlashCompletionRoot) + .distinct() + .forEach { rootPath -> + collectClaudePromptCommandCompletionEntriesForRoot(rootPath).forEach { entry -> + entriesByKey.putIfAbsent(ClaudePromptCommandCompletionKey(entry.kind, entry.command), entry) + } + } + + return entriesByKey.values.sortedWith( + compareBy({ it.command }, { it.kind.ordinal }, { it.sourceKey }), + ) +} + +private fun collectClaudePromptCommandCompletionEntriesForRoot(rootPath: Path): List { + val entriesByKey = LinkedHashMap() + + collectSlashCompletionAncestors(rootPath).forEach { ancestor -> + collectClaudeCommandEntries(ancestor).forEach { entry -> + entriesByKey.putIfAbsent(ClaudePromptCommandCompletionKey(entry.kind, entry.command), entry) + } + collectClaudeSkillEntries(ancestor).forEach { entry -> + entriesByKey.putIfAbsent(ClaudePromptCommandCompletionKey(entry.kind, entry.command), entry) + } + } + + return entriesByKey.values.sortedWith( + compareBy({ it.command }, { it.kind.ordinal }, { it.sourceKey }), + ) +} + +private fun collectClaudeMenuEntries(): List { + return CLAUDE_MENU_COMMANDS.map { command -> + AgentSessionPromptCommandCompletionEntry( + command = command.command, + kind = AgentSessionPromptCommandCompletionKind.MENU, + sourceKey = "builtin:menu:${command.command}", + argumentHint = command.argumentHint, + ) + } +} + +private fun parseSlashCompletionRoot(workingProjectPath: String?): Path? { + val pathString = workingProjectPath?.takeIf { path -> path.isNotBlank() } ?: return null + return try { + val parsedPath = Path.of(pathString).toAbsolutePath().normalize() + if (Files.exists(parsedPath) && !Files.isDirectory(parsedPath)) parsedPath.parent else parsedPath + } + catch (_: InvalidPathException) { + null + } +} + +private fun collectSlashCompletionAncestors(rootPath: Path): Sequence { + return generateSequence(rootPath) { path -> path.parent } +} + +private fun collectClaudeCommandEntries(ancestor: Path): List { + val commandsDirectory = ancestor.resolve(CLAUDE_DIRECTORY).resolve(COMMANDS_DIRECTORY) + if (!Files.isDirectory(commandsDirectory)) { + return emptyList() + } + + Files.newDirectoryStream(commandsDirectory, "*.md").use { stream -> + return stream.asSequence() + .filter { path -> Files.isRegularFile(path) } + .sortedBy { path -> path.fileName.toString() } + .map { path -> + AgentSessionPromptCommandCompletionEntry( + command = "/" + path.fileName.toString().removeSuffix(".md"), + kind = AgentSessionPromptCommandCompletionKind.COMMAND, + sourceKey = path.toString(), + sourcePath = path, + argumentHint = readSlashCompletionArgumentHint(path), + ) + } + .toList() + } +} + +private fun collectClaudeSkillEntries(ancestor: Path): List { + val skillsDirectory = ancestor.resolve(CLAUDE_DIRECTORY).resolve(SKILLS_DIRECTORY) + if (!Files.isDirectory(skillsDirectory)) { + return emptyList() + } + + Files.newDirectoryStream(skillsDirectory).use { stream -> + return stream.asSequence() + .filter { path -> Files.isDirectory(path) } + .filter { path -> Files.isRegularFile(path.resolve(SKILL_FILE_NAME)) } + .sortedBy { path -> path.fileName.toString() } + .map { path -> + val skillFile = path.resolve(SKILL_FILE_NAME) + AgentSessionPromptCommandCompletionEntry( + command = "/" + path.fileName.toString(), + kind = AgentSessionPromptCommandCompletionKind.SKILL, + sourceKey = skillFile.toString(), + sourcePath = skillFile, + argumentHint = readSlashCompletionArgumentHint(skillFile), + ) + } + .toList() + } +} + +private fun readSlashCompletionArgumentHint(path: Path): String { + return readArgumentHintFrontmatterValue(path) ?: "" +} + +private fun readArgumentHintFrontmatterValue(path: Path): String? { + return try { + Files.newBufferedReader(path).use { reader -> + val lines = reader.lineSequence().iterator() + if (!lines.hasNext() || lines.next().trim() != "---") { + return null + } + + while (lines.hasNext()) { + val line = lines.next().trim() + if (line == "---") { + return null + } + + val argumentHint = parseArgumentHintLine(line) + if (argumentHint != null) { + return argumentHint + } + } + + null + } + } + catch (_: IOException) { + null + } +} + +private fun parseArgumentHintLine(line: String): String? { + for (prefix in listOf("argument-hint:", "argument_hint:")) { + if (line.startsWith(prefix)) { + return line.substring(prefix.length) + .trim() + .trimMatchingQuotes() + .takeIf(String::isNotBlank) + } + } + return null +} + +private fun String.trimMatchingQuotes(): String { + if (length >= 2 && first() == last() && (first() == '"' || first() == '\'')) { + return substring(1, lastIndex) + } + return this +} + +private data class ClaudePromptCommandCompletionKey( + val kind: AgentSessionPromptCommandCompletionKind, + val command: String, +) diff --git a/plugins/agent-workbench/lib-agent/providers/claude/sessions/testSrc/ClaudeAgentSessionProviderDescriptorTest.kt b/plugins/agent-workbench/lib-agent/providers/claude/sessions/testSrc/ClaudeAgentSessionProviderDescriptorTest.kt index 59a633dba9af..8160481e7983 100644 --- a/plugins/agent-workbench/lib-agent/providers/claude/sessions/testSrc/ClaudeAgentSessionProviderDescriptorTest.kt +++ b/plugins/agent-workbench/lib-agent/providers/claude/sessions/testSrc/ClaudeAgentSessionProviderDescriptorTest.kt @@ -16,6 +16,7 @@ import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessag import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessagePlan import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessageStartupPolicy import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessageTimeoutPolicy +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource import com.intellij.openapi.project.Project import com.intellij.testFramework.junit5.TestApplication @@ -25,6 +26,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.TimeUnit import java.util.UUID @@ -574,6 +576,133 @@ class ClaudeAgentSessionProviderDescriptorTest { assertThat(renamedNewTitle).isEqualTo("Renamed thread") } } + + @Test + fun builtInMenuCommandsRecognizeRenamePrompt() { + assertThat(bridge.menuCommands.map { command -> command.command }).contains("/rename") + assertThat(bridge.isMenuCommandPrompt("/rename Archived thread")).isTrue() + assertThat(bridge.isMenuCommandPrompt("/review changes")).isFalse() + } + + @Test + fun includesBuiltInClaudeMenuCompletionEntries() { + val entries = bridge.collectPromptCommandCompletionEntries(emptyList()) + + assertThat(entries.filter { entry -> entry.kind == AgentSessionPromptCommandCompletionKind.MENU }.map { entry -> entry.command }) + .contains("/mcp", "/memory", "/model") + assertThat(entries.single { entry -> entry.command == "/compact" }.argumentHint).isEqualTo("[instructions]") + assertThat(entries.single { entry -> entry.command == "/model" }.argumentHint).isEqualTo("[model]") + assertThat(entries.single { entry -> entry.command == "/resume" }.argumentHint).isEqualTo("[session]") + } + + @Test + fun collectsEntriesFromClaudeAncestorChain() { + val workspaceRoot = tempDir.resolve("workspace") + val projectPath = workspaceRoot.resolve("project") + Files.createDirectories(projectPath) + writeCommand(tempDir, "repair") + writeSkill(workspaceRoot, "safe-push") + + assertThat(collectLocallySourcedNonMenuCommands(projectPath)) + .containsExactly("/repair", "/safe-push") + } + + @Test + fun nearerAncestorOverridesSameKindEntries() { + val workspaceRoot = tempDir.resolve("workspace") + val projectPath = workspaceRoot.resolve("project") + Files.createDirectories(projectPath) + val parentCommand = writeCommand(tempDir, "review") + val nearerCommand = writeCommand(workspaceRoot, "review") + + val entries = bridge.collectPromptCommandCompletionEntries(listOf(projectPath.toString())) + .filter { entry -> entry.kind == AgentSessionPromptCommandCompletionKind.COMMAND && entry.command == "/review" } + val entry = entries.single() + + assertThat(entry.command).isEqualTo("/review") + assertThat(entry.kind).isEqualTo(AgentSessionPromptCommandCompletionKind.COMMAND) + assertThat(entry.sourcePath).isEqualTo(nearerCommand) + assertThat(entry.sourcePath).isNotEqualTo(parentCommand) + } + + @Test + fun sameNameCommandAndSkillAreBothReturnedWithCommandFirst() { + val projectPath = tempDir.resolve("project") + Files.createDirectories(projectPath) + writeCommand(tempDir, "review") + writeSkill(tempDir, "review") + + val entries = bridge.collectPromptCommandCompletionEntries(listOf(projectPath.toString())).filter { entry -> entry.command == "/review" } + + assertThat(entries.map { entry -> entry.kind }) + .containsExactly(AgentSessionPromptCommandCompletionKind.COMMAND, AgentSessionPromptCommandCompletionKind.SKILL) + } + + @Test + fun collectsArgumentHintsFromCommandAndSkillFrontmatter() { + val projectPath = tempDir.resolve("project") + Files.createDirectories(projectPath) + writeCommand(tempDir, "review", argumentHint = "[PR number]") + writeSkill(tempDir, "safe-push", argumentHint = "[focus]") + + val entriesByCommand = bridge.collectPromptCommandCompletionEntries(listOf(projectPath.toString())).associateBy { entry -> entry.command } + + assertThat(entriesByCommand["/review"]?.argumentHint).isEqualTo("[PR number]") + assertThat(entriesByCommand["/safe-push"]?.argumentHint).isEqualTo("[focus]") + } + + @Test + fun onlyImmediateClaudeLocationsAreRecognized() { + val projectPath = tempDir.resolve("project") + Files.createDirectories(projectPath) + writeCommand(tempDir, "review") + Files.createDirectories(tempDir.resolve(".claude").resolve("commands").resolve("nested")) + Files.writeString(tempDir.resolve(".claude").resolve("commands").resolve("nested").resolve("ignored.md"), "# nested") + Files.createDirectories(tempDir.resolve(".claude").resolve("skills").resolve("safe-push").resolve("nested")) + Files.writeString( + tempDir.resolve(".claude").resolve("skills").resolve("safe-push").resolve("nested").resolve("SKILL.md"), + "# nested", + ) + + assertThat(collectLocallySourcedNonMenuCommands(projectPath)) + .containsExactly("/review") + } + + private fun collectLocallySourcedNonMenuCommands(projectPath: Path): List { + val testRoot = tempDir.toAbsolutePath().normalize() + return bridge.collectPromptCommandCompletionEntries(listOf(projectPath.toString())) + .asSequence() + .filter { entry -> entry.kind != AgentSessionPromptCommandCompletionKind.MENU } + .filter { entry -> entry.sourcePath?.toAbsolutePath()?.normalize()?.startsWith(testRoot) == true } + .map { entry -> entry.command } + .toList() + } + + private fun writeCommand(root: Path, name: String, argumentHint: String? = null): Path { + val commandFile = root.resolve(".claude").resolve("commands").resolve("$name.md") + Files.createDirectories(commandFile.parent) + Files.writeString(commandFile, buildSlashCompletionContent(name, argumentHint)) + return commandFile + } + + private fun writeSkill(root: Path, name: String, argumentHint: String? = null): Path { + val skillFile = root.resolve(".claude").resolve("skills").resolve(name).resolve("SKILL.md") + Files.createDirectories(skillFile.parent) + Files.writeString(skillFile, buildSlashCompletionContent(name, argumentHint)) + return skillFile + } + + private fun buildSlashCompletionContent(name: String, argumentHint: String?): String { + return buildString { + if (!argumentHint.isNullOrBlank()) { + appendLine("---") + appendLine("argument-hint: $argumentHint") + appendLine("---") + appendLine() + } + append("# $name") + } + } } private val STANDARD_INITIAL_MESSAGE_PLAN: AgentInitialMessagePlan = AgentInitialMessagePlan(message = "Refactor this") diff --git a/plugins/agent-workbench/lib-agent/sessions-core/api-dump.txt b/plugins/agent-workbench/lib-agent/sessions-core/api-dump.txt index 3137a71980ea..0659b55f201b 100644 --- a/plugins/agent-workbench/lib-agent/sessions-core/api-dump.txt +++ b/plugins/agent-workbench/lib-agent/sessions-core/api-dump.txt @@ -617,6 +617,42 @@ f:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionLaunchProfi - sf:launchProfileEditablePayload(com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile):com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile - sf:launchProfileMatchesBuiltIn(com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile,com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile):Z - sf:normalizedUserLaunchProfile(com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile):com.intellij.agent.workbench.prompt.core.AgentPromptLaunchProfile +f:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand +- f:argumentHint:java.lang.String +- f:command:java.lang.String +- (java.lang.String,java.lang.String):V +- b:(java.lang.String,java.lang.String,I,kotlin.jvm.internal.DefaultConstructorMarker):V +- f:component1():java.lang.String +- f:component2():java.lang.String +- f:copy(java.lang.String,java.lang.String):com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand +- bs:copy$default(com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand,java.lang.String,java.lang.String,I,java.lang.Object):com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand +- equals(java.lang.Object):Z +- hashCode():I +f:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +- f:argumentHint:java.lang.String +- f:command:java.lang.String +- f:kind:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- f:sourceKey:java.lang.String +- f:sourcePath:java.nio.file.Path +- (java.lang.String,com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind,java.lang.String,java.nio.file.Path,java.lang.String):V +- b:(java.lang.String,com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind,java.lang.String,java.nio.file.Path,java.lang.String,I,kotlin.jvm.internal.DefaultConstructorMarker):V +- f:component1():java.lang.String +- f:component2():com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- f:component3():java.lang.String +- f:component4():java.nio.file.Path +- f:component5():java.lang.String +- f:copy(java.lang.String,com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind,java.lang.String,java.nio.file.Path,java.lang.String):com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +- bs:copy$default(com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry,java.lang.String,com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind,java.lang.String,java.nio.file.Path,java.lang.String,I,java.lang.Object):com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +- equals(java.lang.Object):Z +- hashCode():I +e:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- java.lang.Enum +- sf:COMMAND:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- sf:MENU:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- sf:SKILL:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- s:getEntries():kotlin.enums.EnumEntries +- s:valueOf(java.lang.String):com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +- s:values():com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind[] e:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderCliVisibilityPolicy - java.lang.Enum - sf:DISCOVER_WHEN_AVAILABLE:com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderCliVisibilityPolicy diff --git a/plugins/agent-workbench/lib-agent/sessions-core/src/providers/AgentSessionProviderDescriptor.kt b/plugins/agent-workbench/lib-agent/sessions-core/src/providers/AgentSessionProviderDescriptor.kt index b70e4bf636b6..f3b44251706c 100644 --- a/plugins/agent-workbench/lib-agent/sessions-core/src/providers/AgentSessionProviderDescriptor.kt +++ b/plugins/agent-workbench/lib-agent/sessions-core/src/providers/AgentSessionProviderDescriptor.kt @@ -17,6 +17,7 @@ import com.intellij.platform.ai.agent.sessions.core.launch.AgentSessionSurfaceId import com.intellij.platform.ai.agent.sessions.core.launch.AgentSessionSurfaces import com.intellij.openapi.project.Project import org.jetbrains.annotations.ApiStatus +import java.nio.file.Path import javax.swing.Icon import javax.swing.JComponent @@ -57,6 +58,25 @@ enum class AgentSessionProviderCliVisibilityPolicy { DISCOVER_WHEN_AVAILABLE, } +data class AgentSessionMenuCommand( + @JvmField val command: String, + @JvmField val argumentHint: String = "", +) + +data class AgentSessionPromptCommandCompletionEntry( + @JvmField val command: String, + @JvmField val kind: AgentSessionPromptCommandCompletionKind, + @JvmField val sourceKey: String, + @JvmField val sourcePath: Path? = null, + @JvmField val argumentHint: String = "", +) + +enum class AgentSessionPromptCommandCompletionKind { + MENU, + COMMAND, + SKILL, +} + typealias AgentThreadRenameAction = suspend (path: String, threadId: String, normalizedName: String) -> Boolean data class AgentInitialMessagePlan( @@ -484,6 +504,25 @@ interface AgentSessionProviderDescriptor { return emptyList() } + val menuCommands: List + get() = emptyList() + + fun isMenuCommandPrompt(prompt: String): Boolean { + val token = prompt.leadingSlashCommandToken() ?: return false + return menuCommands.any { command -> command.command == token } + } + + fun collectPromptCommandCompletionEntries(projectPaths: Iterable): List { + return menuCommands.map { command -> + AgentSessionPromptCommandCompletionEntry( + command = command.command, + kind = AgentSessionPromptCommandCompletionKind.MENU, + sourceKey = command.command, + argumentHint = command.argumentHint, + ) + } + } + fun onConversationOpened() { } @@ -513,7 +552,7 @@ interface AgentSessionProviderDescriptor { fun createToolWindowNorthComponent(project: Project): JComponent? = null - fun shouldStripContextForPrompt(prompt: String): Boolean = false + fun shouldStripContextForPrompt(prompt: String): Boolean = isMenuCommandPrompt(prompt) fun isCliMissingError(throwable: Throwable): Boolean = false @@ -530,6 +569,14 @@ interface AgentSessionProviderDescriptor { } } +private fun String.leadingSlashCommandToken(): String? { + val normalized = trimStart() + if (!normalized.startsWith('/')) { + return null + } + return normalized.takeWhile { char -> !char.isWhitespace() } +} + data class AgentSessionTerminalLaunchSpec( @JvmField val command: List, @JvmField val envVariables: Map = emptyMap(), diff --git a/plugins/agent-workbench/prompt/ui/src/AgentPromptClaudeSlashCompletionProvider.kt b/plugins/agent-workbench/prompt/ui/src/AgentPromptClaudeSlashCompletionProvider.kt deleted file mode 100644 index 6d67c7408a6f..000000000000 --- a/plugins/agent-workbench/prompt/ui/src/AgentPromptClaudeSlashCompletionProvider.kt +++ /dev/null @@ -1,371 +0,0 @@ -// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -package com.intellij.agent.workbench.prompt.ui - -import com.intellij.platform.ai.agent.core.session.AgentSessionProvider -import com.intellij.platform.ai.agent.common.session.claudeMenuCommandEntries -import com.intellij.agent.workbench.prompt.core.AgentPromptReusableSourceEntry -import com.intellij.agent.workbench.prompt.core.AgentPromptReusableSourceKind -import com.intellij.codeInsight.completion.CompletionResultSet -import com.intellij.codeInsight.completion.PlainPrefixMatcher -import com.intellij.codeInsight.lookup.CharFilter -import com.intellij.codeInsight.lookup.LookupElement -import com.intellij.codeInsight.lookup.LookupElementBuilder -import com.intellij.util.TextFieldCompletionProviderDumbAware -import java.io.IOException -import java.nio.file.Files -import java.nio.file.InvalidPathException -import java.nio.file.Path - -private const val CLAUDE_DIRECTORY = ".claude" -private const val COMMANDS_DIRECTORY = "commands" -private const val SKILLS_DIRECTORY = "skills" -private const val SKILL_FILE_NAME = "SKILL.md" -private const val CODEX_SKILL_PREFIX = '$' - -private val CLAUDE_AGENT_SESSION_PROVIDER: AgentSessionProvider = AgentSessionProvider.from("claude") -private val CODEX_AGENT_SESSION_PROVIDER: AgentSessionProvider = AgentSessionProvider.from("codex") - -internal class AgentPromptClaudeSlashCompletionProvider( - private val selectedProvider: () -> AgentSessionProvider?, - private val resolveWorkingProjectPaths: () -> List, - private val resolveCodexSkillEntries: () -> List = { emptyList() }, -) : TextFieldCompletionProviderDumbAware() { - override fun getPrefix(text: String, offset: Int): String? { - return when (selectedProvider()) { - CLAUDE_AGENT_SESSION_PROVIDER -> findClaudeSlashCompletionPrefix(text, offset) - CODEX_AGENT_SESSION_PROVIDER -> findCodexSkillCompletionPrefix(text, offset) - else -> null - } - } - - override fun acceptChar(c: Char): CharFilter.Result? { - return if (c == '/' || c == '$' || c == '-' || c == '_' || c.isLetterOrDigit()) CharFilter.Result.ADD_TO_PREFIX else null - } - - override fun applyPrefixMatcher(result: CompletionResultSet, prefix: String): CompletionResultSet { - return result.withPrefixMatcher(PlainPrefixMatcher(prefix, true)).caseInsensitive() - } - - override fun addCompletionVariants(text: String, offset: Int, prefix: String, result: CompletionResultSet) { - when (selectedProvider()) { - CLAUDE_AGENT_SESSION_PROVIDER -> collectClaudeSlashCompletionEntries(resolveWorkingProjectPaths()).forEach { entry -> - result.addElement(entry.toLookupElement()) - } - CODEX_AGENT_SESSION_PROVIDER -> resolveCodexSkillEntries() - .asSequence() - .filter { entry -> entry.kind == AgentPromptReusableSourceKind.SKILL } - .forEach { entry -> result.addElement(entry.toLookupElement()) } - else -> Unit - } - } -} - -internal fun findClaudeSlashCompletionPrefix(text: CharSequence, offset: Int): String? { - val safeOffset = offset.coerceIn(0, text.length) - val tokenStart = findCompletionTokenStart(text, safeOffset) - if (tokenStart >= safeOffset) { - return null - } - - val prefix = text.subSequence(tokenStart, safeOffset).toString() - return prefix.takeIf { candidate -> candidate.startsWith('/') } -} - -internal fun findCodexSkillCompletionPrefix(text: CharSequence, offset: Int): String? { - val safeOffset = offset.coerceIn(0, text.length) - val tokenStart = findCompletionTokenStart(text, safeOffset) - if (tokenStart >= safeOffset) { - return null - } - - val prefix = text.subSequence(tokenStart, safeOffset).toString() - return prefix.takeIf { candidate -> candidate.startsWith('$') } -} - -private fun findCompletionTokenStart(text: CharSequence, offset: Int): Int { - var index = offset - 1 - while (index >= 0) { - when (text[index]) { - ' ', '\n', '\t', '\r' -> return index + 1 - } - index-- - } - return 0 -} - -internal fun shouldAutoPopupClaudeSlashCompletion( - selectedProvider: AgentSessionProvider?, - workingProjectPaths: Iterable, - text: CharSequence, - offsetAfterChange: Int, - insertedFragment: CharSequence, -): Boolean { - if (selectedProvider != CLAUDE_AGENT_SESSION_PROVIDER) { - return false - } - if (insertedFragment.length != 1 || insertedFragment[0] != '/') { - return false - } - if (offsetAfterChange != 1 || text.isEmpty() || text[0] != '/') { - return false - } - if (findClaudeSlashCompletionPrefix(text, offsetAfterChange) != "/") { - return false - } - return collectClaudeSlashCompletionEntries(workingProjectPaths).isNotEmpty() -} - -internal fun shouldAutoPopupCodexSkillCompletion( - selectedProvider: AgentSessionProvider?, - text: CharSequence, - offsetAfterChange: Int, - insertedFragment: CharSequence, -): Boolean { - if (selectedProvider != CODEX_AGENT_SESSION_PROVIDER) { - return false - } - if (insertedFragment.length != 1 || insertedFragment[0] != '$') { - return false - } - if (offsetAfterChange != 1 || text.isEmpty() || text[0] != '$') { - return false - } - return findCodexSkillCompletionPrefix(text, offsetAfterChange) == CODEX_SKILL_PREFIX.toString() -} - -internal fun collectClaudeSlashCompletionEntries(workingProjectPath: String?): List { - return collectClaudeSlashCompletionEntries(listOfNotNull(workingProjectPath)) -} - -internal fun collectClaudeSlashCompletionEntries(workingProjectPaths: Iterable): List { - val entriesByKey = LinkedHashMap() - - collectClaudeMenuEntries().forEach { entry -> - entriesByKey.putIfAbsent(AgentPromptClaudeSlashCompletionKey(entry.kind, entry.name), entry) - } - - workingProjectPaths - .asSequence() - .mapNotNull(::parseSlashCompletionRoot) - .distinct() - .forEach { rootPath -> - collectClaudeSlashCompletionEntriesForRoot(rootPath).forEach { entry -> - entriesByKey.putIfAbsent(AgentPromptClaudeSlashCompletionKey(entry.kind, entry.name), entry) - } - } - - return entriesByKey.values.sortedWith( - compareBy({ it.name }, { it.kind.sortOrder }, { it.sourceKey }), - ) -} - -private fun collectClaudeSlashCompletionEntriesForRoot(rootPath: Path): List { - val entriesByKey = LinkedHashMap() - - collectSlashCompletionAncestors(rootPath).forEach { ancestor -> - collectClaudeCommandEntries(ancestor).forEach { entry -> - entriesByKey.putIfAbsent(AgentPromptClaudeSlashCompletionKey(entry.kind, entry.name), entry) - } - collectClaudeSkillEntries(ancestor).forEach { entry -> - entriesByKey.putIfAbsent(AgentPromptClaudeSlashCompletionKey(entry.kind, entry.name), entry) - } - } - - return entriesByKey.values.sortedWith( - compareBy({ it.name }, { it.kind.sortOrder }, { it.sourceKey }), - ) -} - -private fun collectClaudeMenuEntries(): List { - return claudeMenuCommandEntries().map { command -> - AgentPromptClaudeSlashCompletionEntry( - name = command.command.removePrefix("/"), - kind = AgentPromptClaudeSlashCompletionKind.MENU, - sourceKey = "builtin:menu:${command.command}", - argumentHint = command.argumentHint, - ) - } -} - -private fun parseSlashCompletionRoot(workingProjectPath: String?): Path? { - val pathString = workingProjectPath?.takeIf { path -> path.isNotBlank() } ?: return null - return try { - val parsedPath = Path.of(pathString).toAbsolutePath().normalize() - if (Files.exists(parsedPath) && !Files.isDirectory(parsedPath)) parsedPath.parent else parsedPath - } - catch (_: InvalidPathException) { - null - } -} - -private fun collectSlashCompletionAncestors(rootPath: Path): Sequence { - return generateSequence(rootPath) { path -> path.parent } -} - -private fun collectClaudeCommandEntries(ancestor: Path): List { - val commandsDirectory = ancestor.resolve(CLAUDE_DIRECTORY).resolve(COMMANDS_DIRECTORY) - if (!Files.isDirectory(commandsDirectory)) { - return emptyList() - } - - Files.newDirectoryStream(commandsDirectory, "*.md").use { stream -> - return stream.asSequence() - .filter { path -> Files.isRegularFile(path) } - .sortedBy { path -> path.fileName.toString() } - .map { path -> - AgentPromptClaudeSlashCompletionEntry( - name = path.fileName.toString().removeSuffix(".md"), - kind = AgentPromptClaudeSlashCompletionKind.COMMAND, - sourceKey = path.toString(), - sourcePath = path, - argumentHint = readSlashCompletionArgumentHint(path), - ) - } - .toList() - } -} - -private fun collectClaudeSkillEntries(ancestor: Path): List { - val skillsDirectory = ancestor.resolve(CLAUDE_DIRECTORY).resolve(SKILLS_DIRECTORY) - if (!Files.isDirectory(skillsDirectory)) { - return emptyList() - } - - Files.newDirectoryStream(skillsDirectory).use { stream -> - return stream.asSequence() - .filter { path -> Files.isDirectory(path) } - .filter { path -> Files.isRegularFile(path.resolve(SKILL_FILE_NAME)) } - .sortedBy { path -> path.fileName.toString() } - .map { path -> - AgentPromptClaudeSlashCompletionEntry( - name = path.fileName.toString(), - kind = AgentPromptClaudeSlashCompletionKind.SKILL, - sourceKey = path.resolve(SKILL_FILE_NAME).toString(), - sourcePath = path.resolve(SKILL_FILE_NAME), - argumentHint = readSlashCompletionArgumentHint(path.resolve(SKILL_FILE_NAME)), - ) - } - .toList() - } -} - -private fun AgentPromptClaudeSlashCompletionEntry.toLookupElement(): LookupElement { - val builder = LookupElementBuilder.create(this, lookupString) - .withPresentableText(lookupString) - .withTypeText(kind.label, true) - - return (argumentHint.takeIf(String::isNotBlank)?.let { hint -> - builder.withTailText(" $hint", true) - } ?: builder).withInsertHandler { context, _ -> - val tailOffset = context.tailOffset - val chars = context.document.charsSequence - if (tailOffset < chars.length && chars[tailOffset].isWhitespace()) { - return@withInsertHandler - } - if (tailOffset == chars.length) { - context.document.insertString(tailOffset, " ") - context.tailOffset = tailOffset + 1 - } - } -} - -private fun AgentPromptReusableSourceEntry.toLookupElement(): LookupElement { - val lookupString = insertText.trim() - val builder = LookupElementBuilder.create(this, lookupString) - .withPresentableText(lookupString) - .withTypeText(AgentPromptBundle.message("popup.completion.type.skill"), true) - - return (description?.takeIf(String::isNotBlank)?.let { hint -> - builder.withTailText(" $hint", true) - } ?: builder).withInsertHandler { context, _ -> - val tailOffset = context.tailOffset - val chars = context.document.charsSequence - if (tailOffset < chars.length && chars[tailOffset].isWhitespace()) { - return@withInsertHandler - } - if (tailOffset == chars.length) { - context.document.insertString(tailOffset, " ") - context.tailOffset = tailOffset + 1 - } - } -} - -private fun readSlashCompletionArgumentHint(path: Path): String { - return readArgumentHintFrontmatterValue(path) ?: "" -} - -private fun readArgumentHintFrontmatterValue(path: Path): String? { - return try { - Files.newBufferedReader(path).use { reader -> - val lines = reader.lineSequence().iterator() - if (!lines.hasNext() || lines.next().trim() != "---") { - return null - } - - while (lines.hasNext()) { - val line = lines.next().trim() - if (line == "---") { - return null - } - - val argumentHint = parseArgumentHintLine(line) - if (argumentHint != null) { - return argumentHint - } - } - - null - } - } - catch (_: IOException) { - null - } -} - -private fun parseArgumentHintLine(line: String): String? { - for (prefix in listOf("argument-hint:", "argument_hint:")) { - if (line.startsWith(prefix)) { - return line.substring(prefix.length) - .trim() - .trimMatchingQuotes() - .takeIf(String::isNotBlank) - } - } - return null -} - -private fun String.trimMatchingQuotes(): String { - if (length >= 2 && first() == last() && (first() == '"' || first() == '\'')) { - return substring(1, lastIndex) - } - return this -} - -internal data class AgentPromptClaudeSlashCompletionEntry( - val name: String, - val kind: AgentPromptClaudeSlashCompletionKind, - val sourceKey: String, - val sourcePath: Path? = null, - val argumentHint: String = "", -) { - val lookupString: String - get() = "/$name" -} - -internal enum class AgentPromptClaudeSlashCompletionKind( - val sortOrder: Int, - private val labelKey: String, -) { - MENU(0, "popup.completion.type.menu"), - COMMAND(1, "popup.completion.type.command"), - SKILL(2, "popup.completion.type.skill"), - ; - - val label: String - get() = AgentPromptBundle.message(labelKey) -} - -private data class AgentPromptClaudeSlashCompletionKey( - val kind: AgentPromptClaudeSlashCompletionKind, - val name: String, -) diff --git a/plugins/agent-workbench/prompt/ui/src/AgentPromptCommandCompletionProvider.kt b/plugins/agent-workbench/prompt/ui/src/AgentPromptCommandCompletionProvider.kt new file mode 100644 index 000000000000..c7b67ca81999 --- /dev/null +++ b/plugins/agent-workbench/prompt/ui/src/AgentPromptCommandCompletionProvider.kt @@ -0,0 +1,184 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.agent.workbench.prompt.ui + +import com.intellij.agent.workbench.prompt.core.AgentPromptReusableSourceEntry +import com.intellij.agent.workbench.prompt.core.AgentPromptReusableSourceKind +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.completion.PlainPrefixMatcher +import com.intellij.codeInsight.lookup.CharFilter +import com.intellij.codeInsight.lookup.LookupElement +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.platform.ai.agent.core.session.AgentSessionProvider +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor +import com.intellij.util.TextFieldCompletionProviderDumbAware + +private const val CODEX_SKILL_PREFIX = '$' + +private val CODEX_AGENT_SESSION_PROVIDER: AgentSessionProvider = AgentSessionProvider.from("codex") + +internal class AgentPromptCommandCompletionProvider( + private val selectedProvider: () -> AgentSessionProviderDescriptor?, + private val resolveWorkingProjectPaths: () -> List, + private val resolveCodexSkillEntries: () -> List = { emptyList() }, +) : TextFieldCompletionProviderDumbAware() { + override fun getPrefix(text: String, offset: Int): String? { + val provider = selectedProvider() + val slashPrefix = findPromptCommandCompletionPrefix(text, offset) + if (slashPrefix != null && provider?.hasPromptCommandCompletion() == true) { + return slashPrefix + } + if (provider?.provider == CODEX_AGENT_SESSION_PROVIDER) { + return findCodexSkillCompletionPrefix(text, offset) + } + return null + } + + override fun acceptChar(c: Char): CharFilter.Result? { + return if (c == '/' || c == '$' || c == '-' || c == '_' || c.isLetterOrDigit()) CharFilter.Result.ADD_TO_PREFIX else null + } + + override fun applyPrefixMatcher(result: CompletionResultSet, prefix: String): CompletionResultSet { + return result.withPrefixMatcher(PlainPrefixMatcher(prefix, true)).caseInsensitive() + } + + override fun addCompletionVariants(text: String, offset: Int, prefix: String, result: CompletionResultSet) { + val provider = selectedProvider() + when { + prefix.startsWith('/') && provider != null -> provider.collectPromptCommandCompletionEntries(resolveWorkingProjectPaths()).forEach { entry -> + result.addElement(entry.toLookupElement()) + } + prefix.startsWith(CODEX_SKILL_PREFIX) && provider?.provider == CODEX_AGENT_SESSION_PROVIDER -> resolveCodexSkillEntries() + .asSequence() + .filter { entry -> entry.kind == AgentPromptReusableSourceKind.SKILL } + .forEach { entry -> result.addElement(entry.toLookupElement()) } + } + } +} + +internal fun findPromptCommandCompletionPrefix(text: CharSequence, offset: Int): String? { + val safeOffset = offset.coerceIn(0, text.length) + val tokenStart = findCompletionTokenStart(text, safeOffset) + if (tokenStart >= safeOffset) { + return null + } + + val prefix = text.subSequence(tokenStart, safeOffset).toString() + return prefix.takeIf { candidate -> candidate.startsWith('/') } +} + +internal fun findCodexSkillCompletionPrefix(text: CharSequence, offset: Int): String? { + val safeOffset = offset.coerceIn(0, text.length) + val tokenStart = findCompletionTokenStart(text, safeOffset) + if (tokenStart >= safeOffset) { + return null + } + + val prefix = text.subSequence(tokenStart, safeOffset).toString() + return prefix.takeIf { candidate -> candidate.startsWith('$') } +} + +private fun findCompletionTokenStart(text: CharSequence, offset: Int): Int { + var index = offset - 1 + while (index >= 0) { + when (text[index]) { + ' ', '\n', '\t', '\r' -> return index + 1 + } + index-- + } + return 0 +} + +internal fun shouldAutoPopupPromptCommandCompletion( + selectedProvider: AgentSessionProviderDescriptor?, + workingProjectPaths: Iterable, + text: CharSequence, + offsetAfterChange: Int, + insertedFragment: CharSequence, +): Boolean { + if (selectedProvider == null) { + return false + } + if (insertedFragment.length != 1 || insertedFragment[0] != '/') { + return false + } + if (offsetAfterChange != 1 || text.isEmpty() || text[0] != '/') { + return false + } + if (findPromptCommandCompletionPrefix(text, offsetAfterChange) != "/") { + return false + } + return selectedProvider.collectPromptCommandCompletionEntries(workingProjectPaths).isNotEmpty() +} + +internal fun shouldAutoPopupCodexSkillCompletion( + selectedProvider: AgentSessionProvider?, + text: CharSequence, + offsetAfterChange: Int, + insertedFragment: CharSequence, +): Boolean { + if (selectedProvider != CODEX_AGENT_SESSION_PROVIDER) { + return false + } + if (insertedFragment.length != 1 || insertedFragment[0] != '$') { + return false + } + if (offsetAfterChange != 1 || text.isEmpty() || text[0] != '$') { + return false + } + return findCodexSkillCompletionPrefix(text, offsetAfterChange) == CODEX_SKILL_PREFIX.toString() +} + +private fun AgentSessionProviderDescriptor.hasPromptCommandCompletion(): Boolean { + return collectPromptCommandCompletionEntries(emptyList()).isNotEmpty() +} + +private fun AgentSessionPromptCommandCompletionEntry.toLookupElement(): LookupElement { + val lookupString = command.trim() + val builder = LookupElementBuilder.create(this, lookupString) + .withPresentableText(lookupString) + .withTypeText(kind.label, true) + + return (argumentHint.takeIf(String::isNotBlank)?.let { hint -> + builder.withTailText(" $hint", true) + } ?: builder).withInsertHandler { context, _ -> + val tailOffset = context.tailOffset + val chars = context.document.charsSequence + if (tailOffset < chars.length && chars[tailOffset].isWhitespace()) { + return@withInsertHandler + } + if (tailOffset == chars.length) { + context.document.insertString(tailOffset, " ") + context.tailOffset = tailOffset + 1 + } + } +} + +private val AgentSessionPromptCommandCompletionKind.label: String + get() = when (this) { + AgentSessionPromptCommandCompletionKind.MENU -> AgentPromptBundle.message("popup.completion.type.menu") + AgentSessionPromptCommandCompletionKind.COMMAND -> AgentPromptBundle.message("popup.completion.type.command") + AgentSessionPromptCommandCompletionKind.SKILL -> AgentPromptBundle.message("popup.completion.type.skill") + } + +private fun AgentPromptReusableSourceEntry.toLookupElement(): LookupElement { + val lookupString = insertText.trim() + val builder = LookupElementBuilder.create(this, lookupString) + .withPresentableText(lookupString) + .withTypeText(AgentPromptBundle.message("popup.completion.type.skill"), true) + + return (description?.takeIf(String::isNotBlank)?.let { hint -> + builder.withTailText(" $hint", true) + } ?: builder).withInsertHandler { context, _ -> + val tailOffset = context.tailOffset + val chars = context.document.charsSequence + if (tailOffset < chars.length && chars[tailOffset].isWhitespace()) { + return@withInsertHandler + } + if (tailOffset == chars.length) { + context.document.insertString(tailOffset, " ") + context.tailOffset = tailOffset + 1 + } + } +} diff --git a/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt b/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt index be93c40f07a1..30a1ec0efd2f 100644 --- a/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt +++ b/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt @@ -236,8 +236,8 @@ internal fun createAgentPromptPaletteContent( try { val promptArea = AgentPromptTextField( project = project, - completionProvider = AgentPromptClaudeSlashCompletionProvider( - selectedProvider = { providerSelectorRef?.selectedProvider?.bridge?.provider }, + completionProvider = AgentPromptCommandCompletionProvider( + selectedProvider = { providerSelectorRef?.selectedProvider?.bridge }, resolveWorkingProjectPaths = { resolveWorkingProjectPathsForCompletion( project = project, @@ -355,7 +355,7 @@ private fun resolveWorkingProjectPathsForCompletion( ?: launcher ?.resolveWorkingProjectPath(invocationData) ?.takeIf { path -> path.isNotBlank() } - return resolveClaudeSlashCompletionProjectPaths( + return resolvePromptCommandCompletionProjectPaths( workingProjectPath = workingProjectPath, sourceProjectBasePath = sourceProjectBasePath, projectBasePath = project.basePath, @@ -377,7 +377,7 @@ internal fun resolveProjectForComponent(component: Component?): Project? { return null } -internal fun resolveClaudeSlashCompletionProjectPaths( +internal fun resolvePromptCommandCompletionProjectPaths( workingProjectPath: String?, sourceProjectBasePath: String?, projectBasePath: String?, diff --git a/plugins/agent-workbench/prompt/ui/src/AgentPromptPaletteSessionController.kt b/plugins/agent-workbench/prompt/ui/src/AgentPromptPaletteSessionController.kt index f765b63c6f14..7a85bb3cb1cb 100644 --- a/plugins/agent-workbench/prompt/ui/src/AgentPromptPaletteSessionController.kt +++ b/plugins/agent-workbench/prompt/ui/src/AgentPromptPaletteSessionController.kt @@ -499,7 +499,7 @@ internal class AgentPromptPaletteSessionController( val sourceProjectBasePath = launcherProvider() ?.resolveSourceProject(invocationData) ?.basePath - return resolveClaudeSlashCompletionProjectPaths( + return resolvePromptCommandCompletionProjectPaths( workingProjectPath = submitController.resolveWorkingProjectPath(), sourceProjectBasePath = sourceProjectBasePath, projectBasePath = project.basePath, @@ -531,14 +531,14 @@ internal class AgentPromptPaletteSessionController( return } - val selectedProvider = providerSelector.selectedProvider?.bridge?.provider + val selectedProvider = providerSelector.selectedProvider?.bridge val documentText = event.document.immutableCharSequence val sourceProjectBasePath = launcherProvider() ?.resolveSourceProject(invocationData) ?.basePath - if (shouldAutoPopupClaudeSlashCompletion( + if (shouldAutoPopupPromptCommandCompletion( selectedProvider = selectedProvider, - workingProjectPaths = resolveClaudeSlashCompletionProjectPaths( + workingProjectPaths = resolvePromptCommandCompletionProjectPaths( workingProjectPath = submitController.resolveWorkingProjectPath(), sourceProjectBasePath = sourceProjectBasePath, projectBasePath = project.basePath, @@ -553,7 +553,7 @@ internal class AgentPromptPaletteSessionController( } if (!shouldAutoPopupCodexSkillCompletion( - selectedProvider = selectedProvider, + selectedProvider = selectedProvider?.provider, text = documentText, offsetAfterChange = event.offset + event.newLength, insertedFragment = event.newFragment, @@ -597,7 +597,7 @@ internal class AgentPromptPaletteSessionController( val text = editor.document.immutableCharSequence val caretOffset = editor.caretModel.offset val currentPrefix = when (expectedPrefix) { - '/' -> findClaudeSlashCompletionPrefix(text, caretOffset) + '/' -> findPromptCommandCompletionPrefix(text, caretOffset) CODEX_SKILL_PREFIX -> findCodexSkillCompletionPrefix(text, caretOffset) else -> null } diff --git a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptClaudeSlashCompletionProviderTest.kt b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptCommandCompletionProviderTest.kt similarity index 51% rename from plugins/agent-workbench/prompt/ui/testSrc/AgentPromptClaudeSlashCompletionProviderTest.kt rename to plugins/agent-workbench/prompt/ui/testSrc/AgentPromptCommandCompletionProviderTest.kt index 5a3efdb6643f..2f3501ec515e 100644 --- a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptClaudeSlashCompletionProviderTest.kt +++ b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptCommandCompletionProviderTest.kt @@ -1,7 +1,7 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.agent.workbench.prompt.ui -import com.intellij.platform.ai.agent.core.session.AgentSessionProvider +import com.intellij.agent.workbench.prompt.core.AgentPromptInitialMessageRequest import com.intellij.agent.workbench.prompt.core.AgentPromptReusableSourceEntry import com.intellij.agent.workbench.prompt.core.AgentPromptReusableSourceKind import com.intellij.codeInsight.completion.CodeCompletionHandlerBase @@ -12,28 +12,37 @@ import com.intellij.codeInsight.lookup.LookupManager import com.intellij.openapi.application.UI import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.project.ProjectManager +import com.intellij.platform.ai.agent.core.session.AgentSessionLaunchMode +import com.intellij.platform.ai.agent.core.session.AgentSessionProvider +import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessagePlan +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionTerminalLaunchSpec import com.intellij.psi.PsiDocumentManager import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.runInEdtAndWait import com.intellij.util.textCompletion.TextCompletionUtil +import com.intellij.util.ui.EmptyIcon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import java.util.concurrent.TimeUnit -import org.junit.jupiter.api.io.TempDir -import java.nio.file.Files -import java.nio.file.Path private const val CODEX_SKILL_PREFIX = '$' +private val CLAUDE_PROVIDER: AgentSessionProvider = AgentSessionProvider.from("claude") +private val CODEX_PROVIDER: AgentSessionProvider = AgentSessionProvider.from("codex") + @TestApplication @Timeout(value = 2, unit = TimeUnit.MINUTES) -class AgentPromptClaudeSlashCompletionProviderTest { +class AgentPromptCommandCompletionProviderTest { @Test - fun slashCompletionIsDisabledForNonClaudeProviders() { - val provider = createProvider(selectedProvider = { AgentSessionProvider.from("codex") }) + fun slashCompletionIsDisabledForProvidersWithoutCommandCompletion() { + val provider = createProvider(selectedProvider = { testProviderDescriptor(provider = CODEX_PROVIDER) }) assertThat(provider.getPrefix("/review", "/review".length)).isNull() assertThat(createProvider(selectedProvider = { null }).getPrefix("/review", "/review".length)).isNull() @@ -41,20 +50,22 @@ class AgentPromptClaudeSlashCompletionProviderTest { @Test fun codexCompletionOnlyUsesDollarPrefixedWhitespaceDelimitedTokens() { - val provider = createProvider(selectedProvider = { AgentSessionProvider.from("codex") }) + val provider = createProvider(selectedProvider = { testProviderDescriptor(provider = CODEX_PROVIDER) }) val teamcitySkill = codexSkillLookup("teamcity-cli") val promptedTeamcitySkill = "please $teamcitySkill" assertThat(provider.getPrefix(teamcitySkill, teamcitySkill.length)).isEqualTo(teamcitySkill) assertThat(provider.getPrefix(promptedTeamcitySkill, promptedTeamcitySkill.length)).isEqualTo(teamcitySkill) assertThat(provider.getPrefix("plain text", "plain text".length)).isNull() - val claudeProvider = createProvider(selectedProvider = { AgentSessionProvider.from("claude") }) + val claudeProvider = createProvider(selectedProvider = { testProviderDescriptor(provider = CLAUDE_PROVIDER) }) assertThat(claudeProvider.getPrefix(teamcitySkill, teamcitySkill.length)).isNull() } @Test fun completionOnlyUsesSlashPrefixedWhitespaceDelimitedTokens() { - val provider = createProvider(selectedProvider = { AgentSessionProvider.from("claude") }) + val provider = createProvider( + selectedProvider = { testProviderDescriptor(commandEntries = listOf(promptCommandEntry("/safe-push"))) }, + ) assertThat(provider.getPrefix("please /safe-push", "please /safe-push".length)).isEqualTo("/safe-push") assertThat(provider.getPrefix("path/to/file", "path/to/file".length)).isNull() @@ -62,8 +73,8 @@ class AgentPromptClaudeSlashCompletionProviderTest { } @Test - fun completionAutopopupAcceptsSlashCommandCharacters() { - val provider = createProvider(selectedProvider = { AgentSessionProvider.from("claude") }) + fun completionAutopopupAcceptsCommandCharacters() { + val provider = createProvider(selectedProvider = { testProviderDescriptor(commandEntries = listOf(promptCommandEntry("/safe-push"))) }) assertThat(provider.acceptChar('/')).isEqualTo(CharFilter.Result.ADD_TO_PREFIX) assertThat(provider.acceptChar('$')).isEqualTo(CharFilter.Result.ADD_TO_PREFIX) @@ -81,7 +92,7 @@ class AgentPromptClaudeSlashCompletionProviderTest { assertThat( shouldAutoPopupCodexSkillCompletion( - selectedProvider = AgentSessionProvider.from("codex"), + selectedProvider = CODEX_PROVIDER, text = CODEX_SKILL_PREFIX.toString(), offsetAfterChange = 1, insertedFragment = CODEX_SKILL_PREFIX.toString(), @@ -90,7 +101,7 @@ class AgentPromptClaudeSlashCompletionProviderTest { assertThat( shouldAutoPopupCodexSkillCompletion( - selectedProvider = AgentSessionProvider.from("codex"), + selectedProvider = CODEX_PROVIDER, text = promptedDollar, offsetAfterChange = promptedDollar.length, insertedFragment = CODEX_SKILL_PREFIX.toString(), @@ -99,7 +110,7 @@ class AgentPromptClaudeSlashCompletionProviderTest { assertThat( shouldAutoPopupCodexSkillCompletion( - selectedProvider = AgentSessionProvider.from("codex"), + selectedProvider = CODEX_PROVIDER, text = teamcitySkill, offsetAfterChange = teamcitySkill.length, insertedFragment = "teamcity-cli", @@ -108,7 +119,7 @@ class AgentPromptClaudeSlashCompletionProviderTest { assertThat( shouldAutoPopupCodexSkillCompletion( - selectedProvider = AgentSessionProvider.from("claude"), + selectedProvider = CLAUDE_PROVIDER, text = CODEX_SKILL_PREFIX.toString(), offsetAfterChange = 1, insertedFragment = CODEX_SKILL_PREFIX.toString(), @@ -117,15 +128,13 @@ class AgentPromptClaudeSlashCompletionProviderTest { } @Test - fun autoPopupOnlyTriggersForLeadingSlashTypedAsFirstPromptCharacter(@TempDir tempDir: Path) { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(projectPath, "review") + fun autoPopupOnlyTriggersForLeadingSlashTypedAsFirstPromptCharacter() { + val descriptor = testProviderDescriptor(commandEntries = listOf(promptCommandEntry("/review"))) assertThat( - shouldAutoPopupClaudeSlashCompletion( - selectedProvider = AgentSessionProvider.from("claude"), - workingProjectPaths = listOf(projectPath.toString()), + shouldAutoPopupPromptCommandCompletion( + selectedProvider = descriptor, + workingProjectPaths = listOf("/project"), text = "/", offsetAfterChange = 1, insertedFragment = "/", @@ -133,9 +142,9 @@ class AgentPromptClaudeSlashCompletionProviderTest { ).isTrue() assertThat( - shouldAutoPopupClaudeSlashCompletion( - selectedProvider = AgentSessionProvider.from("claude"), - workingProjectPaths = listOf(projectPath.toString()), + shouldAutoPopupPromptCommandCompletion( + selectedProvider = descriptor, + workingProjectPaths = listOf("/project"), text = "open /", offsetAfterChange = "open /".length, insertedFragment = "/", @@ -143,9 +152,9 @@ class AgentPromptClaudeSlashCompletionProviderTest { ).isFalse() assertThat( - shouldAutoPopupClaudeSlashCompletion( - selectedProvider = AgentSessionProvider.from("claude"), - workingProjectPaths = listOf(projectPath.toString()), + shouldAutoPopupPromptCommandCompletion( + selectedProvider = descriptor, + workingProjectPaths = listOf("/project"), text = "/review", offsetAfterChange = "/review".length, insertedFragment = "review", @@ -153,9 +162,9 @@ class AgentPromptClaudeSlashCompletionProviderTest { ).isFalse() assertThat( - shouldAutoPopupClaudeSlashCompletion( - selectedProvider = AgentSessionProvider.from("codex"), - workingProjectPaths = listOf(projectPath.toString()), + shouldAutoPopupPromptCommandCompletion( + selectedProvider = testProviderDescriptor(provider = CODEX_PROVIDER), + workingProjectPaths = listOf("/project"), text = "/", offsetAfterChange = 1, insertedFragment = "/", @@ -166,54 +175,43 @@ class AgentPromptClaudeSlashCompletionProviderTest { @Test fun completionPathFallsBackToProjectBasePath() { assertThat( - resolveClaudeSlashCompletionProjectPaths( + resolvePromptCommandCompletionProjectPaths( workingProjectPath = null, sourceProjectBasePath = null, - projectBasePath = "/repo" - ) - ) - .containsExactly("/repo") + projectBasePath = "/repo", + ), + ).containsExactly("/repo") assertThat( - resolveClaudeSlashCompletionProjectPaths( + resolvePromptCommandCompletionProjectPaths( workingProjectPath = "/workspace", sourceProjectBasePath = null, - projectBasePath = "/repo" - ) - ) - .containsExactly("/workspace", "/repo") + projectBasePath = "/repo", + ), + ).containsExactly("/workspace", "/repo") assertThat( - resolveClaudeSlashCompletionProjectPaths( + resolvePromptCommandCompletionProjectPaths( workingProjectPath = null, sourceProjectBasePath = "/source", - projectBasePath = "/dedicated" - ) - ) - .containsExactly("/source", "/dedicated") + projectBasePath = "/dedicated", + ), + ).containsExactly("/source", "/dedicated") } @Test - fun includesBuiltInClaudeMenuEntries() { - val entries = collectClaudeSlashCompletionEntries(emptyList()) - - assertThat(entries.filter { entry -> entry.kind == AgentPromptClaudeSlashCompletionKind.MENU }.map { entry -> entry.lookupString }) - .contains("/mcp", "/memory", "/model") - assertThat(entries.single { entry -> entry.lookupString == "/compact" }.argumentHint).isEqualTo("[instructions]") - assertThat(entries.single { entry -> entry.lookupString == "/model" }.argumentHint).isEqualTo("[model]") - assertThat(entries.single { entry -> entry.lookupString == "/resume" }.argumentHint).isEqualTo("[session]") - } - - @Test - fun invokingBasicCompletionOnPromptFieldShowsClaudeSlashEntries(@TempDir tempDir: Path) { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(projectPath, "review", argumentHint = "[PR number]") - writeSkill(projectPath, "safe-push", argumentHint = "[focus]") - + fun invokingBasicCompletionOnPromptFieldShowsProviderCommandEntries() { runInEdtAndWait { val project = ProjectManager.getInstance().defaultProject val completionProvider = createProvider( - selectedProvider = { AgentSessionProvider.from("claude") }, - workingProjectPaths = { listOf(projectPath.toString()) }, + selectedProvider = { + testProviderDescriptor( + commandEntries = listOf( + promptCommandEntry("/mcp"), + promptCommandEntry("/model", argumentHint = "[model]"), + promptCommandEntry("/review", kind = AgentSessionPromptCommandCompletionKind.COMMAND, argumentHint = "[PR number]"), + promptCommandEntry("/safe-push", kind = AgentSessionPromptCommandCompletionKind.SKILL, argumentHint = "[focus]"), + ), + ) + }, ) val textField = AgentPromptTextField(project, completionProvider) textField.addNotify() @@ -227,9 +225,7 @@ class AgentPromptClaudeSlashCompletionProviderTest { val lookup = LookupManager.getActiveLookup(editor) assertThat(lookup).isNotNull val activeLookup = checkNotNull(lookup) - assertThat(activeLookup.items.map { item -> item.lookupString }).contains("/mcp") - assertThat(activeLookup.items.map { item -> item.lookupString }).contains("/review") - assertThat(activeLookup.items.map { item -> item.lookupString }).contains("/safe-push") + assertThat(activeLookup.items.map { item -> item.lookupString }).contains("/mcp", "/review", "/safe-push") val tailTextByLookup = activeLookup.items.associate { item -> item.lookupString to LookupElementPresentation.renderElement(item).tailText @@ -249,7 +245,7 @@ class AgentPromptClaudeSlashCompletionProviderTest { runInEdtAndWait { val project = ProjectManager.getInstance().defaultProject val completionProvider = createProvider( - selectedProvider = { AgentSessionProvider.from("codex") }, + selectedProvider = { testProviderDescriptor(provider = CODEX_PROVIDER) }, codexSkillEntries = { listOf( codexSkillEntry("teamcity-cli", "Inspect TeamCity builds"), @@ -284,90 +280,10 @@ class AgentPromptClaudeSlashCompletionProviderTest { } } - @Test - fun collectsEntriesFromClaudeAncestorChain(@TempDir tempDir: Path) { - val workspaceRoot = tempDir.resolve("workspace") - val projectPath = workspaceRoot.resolve("project") - Files.createDirectories(projectPath) - writeCommand(tempDir, "repair") - writeSkill(workspaceRoot, "safe-push") - - val entries = collectClaudeSlashCompletionEntries(projectPath.toString()) - - assertThat(entries.filter { entry -> entry.kind != AgentPromptClaudeSlashCompletionKind.MENU } - .map(AgentPromptClaudeSlashCompletionEntry::lookupString)) - .containsExactly("/repair", "/safe-push") - } - - @Test - fun nearerAncestorOverridesSameKindEntries(@TempDir tempDir: Path) { - val workspaceRoot = tempDir.resolve("workspace") - val projectPath = workspaceRoot.resolve("project") - Files.createDirectories(projectPath) - val parentCommand = writeCommand(tempDir, "review") - val nearerCommand = writeCommand(workspaceRoot, "review") - - val entries = collectClaudeSlashCompletionEntries(projectPath.toString()) - .filter { entry -> entry.kind == AgentPromptClaudeSlashCompletionKind.COMMAND && entry.name == "review" } - val entry = entries.single() - - assertThat(entry.lookupString).isEqualTo("/review") - assertThat(entry.kind).isEqualTo(AgentPromptClaudeSlashCompletionKind.COMMAND) - assertThat(entry.sourcePath).isEqualTo(nearerCommand) - assertThat(entry.sourcePath).isNotEqualTo(parentCommand) - } - - @Test - fun sameNameCommandAndSkillAreBothReturnedWithCommandFirst(@TempDir tempDir: Path) { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(tempDir, "review") - writeSkill(tempDir, "review") - - val entries = collectClaudeSlashCompletionEntries(projectPath.toString()).filter { entry -> entry.name == "review" } - - assertThat(entries.map(AgentPromptClaudeSlashCompletionEntry::kind)) - .containsExactly(AgentPromptClaudeSlashCompletionKind.COMMAND, AgentPromptClaudeSlashCompletionKind.SKILL) - } - - @Test - fun collectsArgumentHintsFromCommandAndSkillFrontmatter(@TempDir tempDir: Path) { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(tempDir, "review", argumentHint = "[PR number]") - writeSkill(tempDir, "safe-push", argumentHint = "[focus]") - - val entriesByLookup = - collectClaudeSlashCompletionEntries(projectPath.toString()).associateBy(AgentPromptClaudeSlashCompletionEntry::lookupString) - - assertThat(entriesByLookup["/review"]?.argumentHint).isEqualTo("[PR number]") - assertThat(entriesByLookup["/safe-push"]?.argumentHint).isEqualTo("[focus]") - } - - @Test - fun onlyImmediateClaudeLocationsAreRecognized(@TempDir tempDir: Path) { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(tempDir, "review") - Files.createDirectories(tempDir.resolve(".claude").resolve("commands").resolve("nested")) - Files.writeString(tempDir.resolve(".claude").resolve("commands").resolve("nested").resolve("ignored.md"), "# nested") - Files.createDirectories(tempDir.resolve(".claude").resolve("skills").resolve("safe-push").resolve("nested")) - Files.writeString( - tempDir.resolve(".claude").resolve("skills").resolve("safe-push").resolve("nested").resolve("SKILL.md"), - "# nested" - ) - - val entries = collectClaudeSlashCompletionEntries(projectPath.toString()) - - assertThat(entries.filter { entry -> entry.kind != AgentPromptClaudeSlashCompletionKind.MENU } - .map(AgentPromptClaudeSlashCompletionEntry::lookupString)) - .containsExactly("/review") - } - @Test fun textFieldUsesTextDocumentAndKeepsCompletionProviderInstalled() { val project = ProjectManager.getInstance().defaultProject - val completionProvider = createProvider(selectedProvider = { AgentSessionProvider.from("claude") }) + val completionProvider = createProvider(selectedProvider = { testProviderDescriptor(commandEntries = listOf(promptCommandEntry("/mcp"))) }) val document = runBlocking(Dispatchers.UI) { AgentPromptTextField(project, completionProvider).document } @@ -380,11 +296,57 @@ class AgentPromptClaudeSlashCompletionProviderTest { } private fun createProvider( - selectedProvider: () -> AgentSessionProvider?, + selectedProvider: () -> AgentSessionProviderDescriptor?, workingProjectPaths: () -> List = { emptyList() }, codexSkillEntries: () -> List = { emptyList() }, - ): AgentPromptClaudeSlashCompletionProvider { - return AgentPromptClaudeSlashCompletionProvider(selectedProvider, workingProjectPaths, codexSkillEntries) + ): AgentPromptCommandCompletionProvider { + return AgentPromptCommandCompletionProvider(selectedProvider, workingProjectPaths, codexSkillEntries) + } + + private fun testProviderDescriptor( + provider: AgentSessionProvider = CLAUDE_PROVIDER, + commandEntries: List = emptyList(), + ): AgentSessionProviderDescriptor { + return object : AgentSessionProviderDescriptor { + override val provider: AgentSessionProvider = provider + override val displayNameKey: String = "provider.${provider.value}" + override val newSessionLabelKey: String = displayNameKey + override val sessionSource: AgentSessionSource + get() = error("Not required for this test") + override val cliMissingMessageKey: String = displayNameKey + override val icon = EmptyIcon.ICON_16 + + override suspend fun isCliAvailable(): Boolean = true + + override suspend fun buildResumeLaunchSpec(sessionId: String): AgentSessionTerminalLaunchSpec { + return AgentSessionTerminalLaunchSpec(command = emptyList()) + } + + override suspend fun buildNewSessionLaunchSpec(mode: AgentSessionLaunchMode): AgentSessionTerminalLaunchSpec { + return AgentSessionTerminalLaunchSpec(command = emptyList()) + } + + override fun buildInitialMessagePlan(request: AgentPromptInitialMessageRequest): AgentInitialMessagePlan { + return AgentInitialMessagePlan.EMPTY + } + + override fun collectPromptCommandCompletionEntries(projectPaths: Iterable): List { + return commandEntries + } + } + } + + private fun promptCommandEntry( + command: String, + kind: AgentSessionPromptCommandCompletionKind = AgentSessionPromptCommandCompletionKind.MENU, + argumentHint: String = "", + ): AgentSessionPromptCommandCompletionEntry { + return AgentSessionPromptCommandCompletionEntry( + command = command, + kind = kind, + sourceKey = "test:$command", + argumentHint = argumentHint, + ) } private fun codexSkillEntry(name: String, description: String): AgentPromptReusableSourceEntry { @@ -393,36 +355,10 @@ class AgentPromptClaudeSlashCompletionProviderTest { label = name, insertText = codexSkillLookup(name) + " ", kind = AgentPromptReusableSourceKind.SKILL, - provider = AgentSessionProvider.from("codex"), + provider = CODEX_PROVIDER, description = description, ) } - - private fun writeCommand(root: Path, name: String, argumentHint: String? = null): Path { - val commandFile = root.resolve(".claude").resolve("commands").resolve("$name.md") - Files.createDirectories(commandFile.parent) - Files.writeString(commandFile, buildSlashCompletionContent(name, argumentHint)) - return commandFile - } - - private fun writeSkill(root: Path, name: String, argumentHint: String? = null): Path { - val skillFile = root.resolve(".claude").resolve("skills").resolve(name).resolve("SKILL.md") - Files.createDirectories(skillFile.parent) - Files.writeString(skillFile, buildSlashCompletionContent(name, argumentHint)) - return skillFile - } - - private fun buildSlashCompletionContent(name: String, argumentHint: String?): String { - return buildString { - if (!argumentHint.isNullOrBlank()) { - appendLine("---") - appendLine("argument-hint: $argumentHint") - appendLine("---") - appendLine() - } - append("# $name") - } - } } private fun codexSkillLookup(name: String): String = CODEX_SKILL_PREFIX.toString() + name diff --git a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptEnterHandlersTest.kt b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptEnterHandlersTest.kt index 8cb6374fb009..cce90ca35bf9 100644 --- a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptEnterHandlersTest.kt +++ b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptEnterHandlersTest.kt @@ -1,7 +1,7 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.agent.workbench.prompt.ui -import com.intellij.platform.ai.agent.core.session.AgentSessionProvider +import com.intellij.agent.workbench.prompt.core.AgentPromptInitialMessageRequest import com.intellij.codeInsight.completion.CodeCompletionHandlerBase import com.intellij.codeInsight.completion.CompletionType import com.intellij.codeInsight.lookup.LookupManager @@ -11,22 +11,28 @@ import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.KeyboardShortcut import com.intellij.openapi.actionSystem.impl.SimpleDataContext import com.intellij.openapi.project.ProjectManager +import com.intellij.platform.ai.agent.core.session.AgentSessionLaunchMode +import com.intellij.platform.ai.agent.core.session.AgentSessionProvider +import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessagePlan +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionEntry +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionTerminalLaunchSpec import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.runInEdtAndWait import com.intellij.ui.ClientProperty import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBList import com.intellij.ui.components.JBTabbedPane +import com.intellij.util.ui.EmptyIcon import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.Timeout import java.util.concurrent.TimeUnit -import org.junit.jupiter.api.io.TempDir import java.awt.event.ActionEvent import java.awt.event.InputEvent import java.awt.event.KeyEvent -import java.nio.file.Files -import java.nio.file.Path import javax.swing.JComponent import javax.swing.JPanel import javax.swing.KeyStroke @@ -52,19 +58,21 @@ class AgentPromptEnterHandlersTest { } @Test - fun enterChoosesActiveLookupItemInsteadOfSubmitting(@TempDir tempDir: Path) { + fun enterChoosesActiveLookupItemInsteadOfSubmitting() { runInEdtAndWait { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(projectPath, "review") - writeCommand(projectPath, "safe-push") - val project = ProjectManager.getInstance().defaultProject val promptArea = AgentPromptTextField( project, - AgentPromptClaudeSlashCompletionProvider( - selectedProvider = { AgentSessionProvider.from("claude") }, - resolveWorkingProjectPaths = { listOf(projectPath.toString()) }, + AgentPromptCommandCompletionProvider( + selectedProvider = { + testProviderDescriptor( + listOf( + promptCommandEntry("/review"), + promptCommandEntry("/safe-push"), + ), + ) + }, + resolveWorkingProjectPaths = { emptyList() }, ), ) var submitCalls = 0 @@ -161,19 +169,21 @@ class AgentPromptEnterHandlersTest { } @Test - fun tabChoosesActiveLookupItemInsteadOfPromptTabAction(@TempDir tempDir: Path) { + fun tabChoosesActiveLookupItemInsteadOfPromptTabAction() { runInEdtAndWait { - val projectPath = tempDir.resolve("project") - Files.createDirectories(projectPath) - writeCommand(projectPath, "review") - writeCommand(projectPath, "safe-push") - val project = ProjectManager.getInstance().defaultProject val promptArea = AgentPromptTextField( project, - AgentPromptClaudeSlashCompletionProvider( - selectedProvider = { AgentSessionProvider.from("claude") }, - resolveWorkingProjectPaths = { listOf(projectPath.toString()) }, + AgentPromptCommandCompletionProvider( + selectedProvider = { + testProviderDescriptor( + listOf( + promptCommandEntry("/review"), + promptCommandEntry("/safe-push"), + ), + ) + }, + resolveWorkingProjectPaths = { emptyList() }, ), ) var submitCalls = 0 @@ -385,10 +395,41 @@ class AgentPromptEnterHandlersTest { assertThat(LookupManager.getActiveLookup(editor)).isNotNull } - private fun writeCommand(root: Path, name: String): Path { - val commandFile = root.resolve(".claude").resolve("commands").resolve("$name.md") - Files.createDirectories(commandFile.parent) - Files.writeString(commandFile, "# $name") - return commandFile + private fun testProviderDescriptor(commandEntries: List): AgentSessionProviderDescriptor { + return object : AgentSessionProviderDescriptor { + override val provider: AgentSessionProvider = AgentSessionProvider.from("claude") + override val displayNameKey: String = "provider.claude" + override val newSessionLabelKey: String = displayNameKey + override val sessionSource: AgentSessionSource + get() = error("Not required for this test") + override val cliMissingMessageKey: String = displayNameKey + override val icon = EmptyIcon.ICON_16 + + override suspend fun isCliAvailable(): Boolean = true + + override suspend fun buildResumeLaunchSpec(sessionId: String): AgentSessionTerminalLaunchSpec { + return AgentSessionTerminalLaunchSpec(command = emptyList()) + } + + override suspend fun buildNewSessionLaunchSpec(mode: AgentSessionLaunchMode): AgentSessionTerminalLaunchSpec { + return AgentSessionTerminalLaunchSpec(command = emptyList()) + } + + override fun buildInitialMessagePlan(request: AgentPromptInitialMessageRequest): AgentInitialMessagePlan { + return AgentInitialMessagePlan.EMPTY + } + + override fun collectPromptCommandCompletionEntries(projectPaths: Iterable): List { + return commandEntries + } + } + } + + private fun promptCommandEntry(command: String): AgentSessionPromptCommandCompletionEntry { + return AgentSessionPromptCommandCompletionEntry( + command = command, + kind = AgentSessionPromptCommandCompletionKind.COMMAND, + sourceKey = "test:$command", + ) } } diff --git a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPalettePopupServiceTest.kt b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPalettePopupServiceTest.kt index 288905916ad2..63b89d72930d 100644 --- a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPalettePopupServiceTest.kt +++ b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPalettePopupServiceTest.kt @@ -139,7 +139,7 @@ class AgentPromptPalettePopupServiceTest { val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) assertThat(psiFile).isNotNull() - assertThat(TextCompletionUtil.getProvider(psiFile!!)).isInstanceOf(AgentPromptClaudeSlashCompletionProvider::class.java) + assertThat(TextCompletionUtil.getProvider(psiFile!!)).isInstanceOf(AgentPromptCommandCompletionProvider::class.java) } finally { withContext(Dispatchers.EDT) { diff --git a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPaletteSubmitControllerTest.kt b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPaletteSubmitControllerTest.kt index 473ea1a47149..71f8aa3d7093 100644 --- a/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPaletteSubmitControllerTest.kt +++ b/plugins/agent-workbench/prompt/ui/testSrc/AgentPromptPaletteSubmitControllerTest.kt @@ -23,7 +23,6 @@ import com.intellij.openapi.application.UI import com.intellij.openapi.components.service import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer -import com.intellij.platform.ai.agent.common.session.isClaudeMenuCommandPrompt import com.intellij.platform.ai.agent.core.AgentThreadActivity import com.intellij.platform.ai.agent.core.AgentThreadActivityReport import com.intellij.platform.ai.agent.core.session.AgentSessionLaunchMode @@ -34,6 +33,7 @@ import com.intellij.platform.ai.agent.sessions.core.providers.AGENT_PROMPT_PROVI import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessagePlan import com.intellij.platform.ai.agent.sessions.core.providers.AgentInitialMessageStartupPolicy import com.intellij.platform.ai.agent.sessions.core.providers.AgentPromptProviderOption +import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionMenuCommand import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionProviderDescriptor import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionSource import com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionTerminalLaunchSpec @@ -791,6 +791,12 @@ class AgentPromptPaletteSubmitControllerTest { override val displayNameKey: String = if (provider == AgentSessionProvider.from("claude")) "provider.claude" else "provider.codex" override val newSessionLabelKey: String = displayNameKey override val promptOptions: List = promptOptions + override val menuCommands: List = if (provider == AgentSessionProvider.from("claude")) { + listOf(AgentSessionMenuCommand("/rename", "[title]")) + } + else { + emptyList() + } override val sessionSource: AgentSessionSource get() = error("Not required for this test") override val cliMissingMessageKey: String = displayNameKey @@ -809,10 +815,6 @@ class AgentPromptPaletteSubmitControllerTest { override fun buildInitialMessagePlan(request: AgentPromptInitialMessageRequest): AgentInitialMessagePlan { return initialMessagePlanBuilder(request) } - - override fun shouldStripContextForPrompt(prompt: String): Boolean { - return this.provider == AgentSessionProvider.from("claude") && prompt.isClaudeMenuCommandPrompt() - } } } diff --git a/plugins/agent-workbench/spec/actions/global-prompt-entry.spec.md b/plugins/agent-workbench/spec/actions/global-prompt-entry.spec.md index 4d5de177a09b..e98f068d1fd7 100644 --- a/plugins/agent-workbench/spec/actions/global-prompt-entry.spec.md +++ b/plugins/agent-workbench/spec/actions/global-prompt-entry.spec.md @@ -70,7 +70,7 @@ The global prompt opens a project-scoped prompt surface for starting a new task [@test] ../../prompt/ui/testSrc/AgentPromptFooterHintDecisionsTest.kt - Claude slash completion is available only for Claude provider prompts and only for explicit slash-token completion; it merges built-in Claude menu commands with project `.claude/commands` and `.claude/skills` definitions. - [@test] ../../prompt/ui/testSrc/AgentPromptClaudeSlashCompletionProviderTest.kt + [@test] ../../prompt/ui/testSrc/AgentPromptCommandCompletionProviderTest.kt - New-task provider, mode, and generation defaults restore through launch profiles. Prompt drafts may persist provider options and container mode, but they must not persist a separate provider id default. [@test] ../../prompt/ui/testSrc/AgentPromptLaunchProfileStateTest.kt diff --git a/plugins/agent-workbench/spec/actions/global-prompt-reusable-sources.spec.md b/plugins/agent-workbench/spec/actions/global-prompt-reusable-sources.spec.md index 2c92cf72931e..5bf802643172 100644 --- a/plugins/agent-workbench/spec/actions/global-prompt-reusable-sources.spec.md +++ b/plugins/agent-workbench/spec/actions/global-prompt-reusable-sources.spec.md @@ -6,7 +6,7 @@ targets: - ../../prompt/core/src/AgentPromptLauncherBridge.kt - ../../prompt/ui/src/AgentPromptLibraryModel.kt - ../../prompt/ui/src/AgentPromptPaletteSessionController.kt - - ../../prompt/ui/src/AgentPromptClaudeSlashCompletionProvider.kt + - ../../prompt/ui/src/AgentPromptCommandCompletionProvider.kt - ../../prompt/ui/src/AgentPromptReusableSourceCollector.kt - ../../prompt/ui/src/AgentPromptUiSessionStateService.kt - ../../prompt/ui/testSrc/AgentPrompt*Test.kt @@ -52,13 +52,13 @@ The global prompt popup lets users reuse saved prompts, recent submitted prompts [@test] ../../prompt/ui/testSrc/AgentPromptReusableSourceCollectorTest.kt - Claude command completion must reuse existing slash-completion discovery for built-in menu commands, project commands, and skills. Accepted Claude entries insert their slash invocation text. - [@test] ../../prompt/ui/testSrc/AgentPromptClaudeSlashCompletionProviderTest.kt + [@test] ../../prompt/ui/testSrc/AgentPromptCommandCompletionProviderTest.kt - Provider-specific completion entries must flow through `AgentPromptLauncherBridge` and `AgentSessionProviderDescriptor` so prompt UI does not call provider session internals directly. [@test] ../../sessions/testSrc/AgentSessionPromptLauncherBridgeTest.kt - Codex skill completion entries must come from the app-server `skills/list` method through `AgentPromptLauncherBridge`, include only enabled skills, and insert `$skillName ` for accepted entries. - [@test] ../../prompt/ui/testSrc/AgentPromptClaudeSlashCompletionProviderTest.kt + [@test] ../../prompt/ui/testSrc/AgentPromptCommandCompletionProviderTest.kt [@test] ../../lib-agent/providers/codex/common/testSrc/CodexAppServerProtocolTest.kt ## User Experience