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