IJPL-248623 Move Claude command handling to provider

Claude slash menu handling was exposed from the common module, which made shared prompt and chat code depend on one provider's command model.

Add provider-neutral command metadata to sessions-core and let the Claude descriptor own built-in commands plus .claude command and skill discovery. Prompt completion and initial-message dispatch now ask the selected descriptor, so provider-specific command behavior stays with the provider implementation.

GitOrigin-RevId: 84dc9d6172e3c0c584de5d7ae9a243b938b87e84
This commit is contained in:
Vladimir Krivosheev
2026-07-09 18:59:27 +00:00
committed by intellij-monorepo-bot
parent d6c2a23c15
commit e7a8bc07ea
23 changed files with 880 additions and 700 deletions
@@ -59,8 +59,6 @@
<extensions defaultExtensionNs="com.intellij.agent.workbench">
<settingsContributor implementation="com.intellij.agent.workbench.chat.AgentChatSettingsContributor"/>
<chatProviderBehavior providerId="claude"
implementation="com.intellij.agent.workbench.chat.ClaudeAgentChatProviderBehavior"/>
</extensions>
<actions resource-bundle="messages.AgentChatBundle">
@@ -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
}
@@ -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()
}
}
@@ -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<AgentSessionMenuCommand> = 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<AgentSessionThread>,
@@ -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<TerminalKeyEvent> = emptyFlow()
override val terminalView: TerminalView? = null
val events: MutableList<String> = mutableListOf()
val sentInitialMessageTexts: MutableList<SentInitialMessageText> = 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<AgentSessionMenuCommand> = emptyList(),
) : AgentSessionProviderDescriptor {
override val provider: AgentSessionProvider = AgentSessionProvider.from("codex")
override val displayNameKey: String = "test.provider.codex"
override val newSessionLabelKey: String = "test.new.codex"
@@ -34,19 +34,3 @@ f:com.intellij.platform.ai.agent.common.icons.AgentWorkbenchCommonIcons
- sf:OpencodeGray:javax.swing.Icon
- sf:PI:javax.swing.Icon
- <init>():V
f:com.intellij.platform.ai.agent.common.session.ClaudeMenuCommand
- <init>(java.lang.String,java.lang.String):V
- b:<init>(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
@@ -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<ClaudeMenuCommand> = 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<String> = CLAUDE_MENU_COMMANDS.map(ClaudeMenuCommand::command)
fun claudeMenuCommandEntries(): List<ClaudeMenuCommand> = 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()
}
@@ -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()
}
}
@@ -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<AgentSessionMenuCommand>
get() = CLAUDE_MENU_COMMANDS
override fun collectPromptCommandCompletionEntries(projectPaths: Iterable<String?>): List<AgentSessionPromptCommandCompletionEntry> {
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,
@@ -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<AgentSessionMenuCommand> = 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<String?>): List<AgentSessionPromptCommandCompletionEntry> {
val entriesByKey = LinkedHashMap<ClaudePromptCommandCompletionKey, AgentSessionPromptCommandCompletionEntry>()
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<AgentSessionPromptCommandCompletionEntry> {
val entriesByKey = LinkedHashMap<ClaudePromptCommandCompletionKey, AgentSessionPromptCommandCompletionEntry>()
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<AgentSessionPromptCommandCompletionEntry> {
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<Path> {
return generateSequence(rootPath) { path -> path.parent }
}
private fun collectClaudeCommandEntries(ancestor: Path): List<AgentSessionPromptCommandCompletionEntry> {
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<AgentSessionPromptCommandCompletionEntry> {
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,
)
@@ -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<String>())
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<String> {
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")
@@ -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
- <init>(java.lang.String,java.lang.String):V
- b:<init>(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
- <init>(java.lang.String,com.intellij.platform.ai.agent.sessions.core.providers.AgentSessionPromptCommandCompletionKind,java.lang.String,java.nio.file.Path,java.lang.String):V
- b:<init>(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
@@ -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<AgentSessionMenuCommand>
get() = emptyList()
fun isMenuCommandPrompt(prompt: String): Boolean {
val token = prompt.leadingSlashCommandToken() ?: return false
return menuCommands.any { command -> command.command == token }
}
fun collectPromptCommandCompletionEntries(projectPaths: Iterable<String?>): List<AgentSessionPromptCommandCompletionEntry> {
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<String>,
@JvmField val envVariables: Map<String, String> = emptyMap(),
@@ -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<String>,
private val resolveCodexSkillEntries: () -> List<AgentPromptReusableSourceEntry> = { 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<String?>,
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<AgentPromptClaudeSlashCompletionEntry> {
return collectClaudeSlashCompletionEntries(listOfNotNull(workingProjectPath))
}
internal fun collectClaudeSlashCompletionEntries(workingProjectPaths: Iterable<String?>): List<AgentPromptClaudeSlashCompletionEntry> {
val entriesByKey = LinkedHashMap<AgentPromptClaudeSlashCompletionKey, AgentPromptClaudeSlashCompletionEntry>()
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<AgentPromptClaudeSlashCompletionEntry> {
val entriesByKey = LinkedHashMap<AgentPromptClaudeSlashCompletionKey, AgentPromptClaudeSlashCompletionEntry>()
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<AgentPromptClaudeSlashCompletionEntry> {
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<Path> {
return generateSequence(rootPath) { path -> path.parent }
}
private fun collectClaudeCommandEntries(ancestor: Path): List<AgentPromptClaudeSlashCompletionEntry> {
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<AgentPromptClaudeSlashCompletionEntry> {
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,
)
@@ -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<String>,
private val resolveCodexSkillEntries: () -> List<AgentPromptReusableSourceEntry> = { 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<String?>,
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
}
}
}
@@ -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?,
@@ -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
}
@@ -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<String>())
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<String> = { emptyList() },
codexSkillEntries: () -> List<AgentPromptReusableSourceEntry> = { emptyList() },
): AgentPromptClaudeSlashCompletionProvider {
return AgentPromptClaudeSlashCompletionProvider(selectedProvider, workingProjectPaths, codexSkillEntries)
): AgentPromptCommandCompletionProvider {
return AgentPromptCommandCompletionProvider(selectedProvider, workingProjectPaths, codexSkillEntries)
}
private fun testProviderDescriptor(
provider: AgentSessionProvider = CLAUDE_PROVIDER,
commandEntries: List<AgentSessionPromptCommandCompletionEntry> = 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<String?>): List<AgentSessionPromptCommandCompletionEntry> {
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
@@ -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<AgentSessionPromptCommandCompletionEntry>): 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<String?>): List<AgentSessionPromptCommandCompletionEntry> {
return commandEntries
}
}
}
private fun promptCommandEntry(command: String): AgentSessionPromptCommandCompletionEntry {
return AgentSessionPromptCommandCompletionEntry(
command = command,
kind = AgentSessionPromptCommandCompletionKind.COMMAND,
sourceKey = "test:$command",
)
}
}
@@ -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) {
@@ -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<AgentPromptProviderOption> = promptOptions
override val menuCommands: List<AgentSessionMenuCommand> = 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()
}
}
}
@@ -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
@@ -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