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