mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
d6c2a23c15
commit
e7a8bc07ea
@@ -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()
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -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,
|
||||
|
||||
+202
@@ -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,
|
||||
)
|
||||
+129
@@ -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
|
||||
|
||||
+48
-1
@@ -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
|
||||
}
|
||||
|
||||
+117
-181
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user