diff --git a/.changeset/jetbrains-slash-completion.md b/.changeset/jetbrains-slash-completion.md new file mode 100644 index 0000000000..e2ec3c4996 --- /dev/null +++ b/.changeset/jetbrains-slash-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve slash command completion to match separators, camel-case humps, and contained command names. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index a2b6331bbb..f7f5f3617d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -20,7 +20,10 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.DumbAware import com.intellij.openapi.progress.runBlockingCancellable +import com.intellij.psi.codeStyle.MinusculeMatcher +import com.intellij.psi.codeStyle.NameUtil import com.intellij.util.textCompletion.TextCompletionProvider +import com.intellij.util.text.matching.MatchingMode import java.util.Collections import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -163,12 +166,18 @@ class KiloPromptCompletionProvider( private fun slash(prefix: String, result: CompletionResultSet) { result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) + val rank = Ranker(prefix) val names = clientTokens() - val clients = actions.filter { action -> matches(prefix, action.name, action.hints) } - clients.forEach { action -> out.addElement(client(action)) } + val clients = actions.mapNotNull { action -> + rank.score(action.name, action.hints)?.let { Hit(client(action), it) } + } val commands = workspace.state.value.commands - .filter { it.name !in names && matches(prefix, it.name, it.hints) } - commands.forEach { command -> out.addElement(server(command)) } + .mapNotNull { command -> + if (command.name in names) return@mapNotNull null + rank.score(command.name, command.hints)?.let { Hit(server(command), it) } + } + val hits = (clients + commands).sortedByDescending { it.score } + hits.forEach { hit -> out.addElement(weight(hit.item, hit.score)) } if (clients.isNotEmpty() || commands.isNotEmpty()) return result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) .addElement(info(prefix, KiloBundle.message("prompt.completion.noMatches"))) @@ -178,7 +187,8 @@ class KiloPromptCompletionProvider( result.restartCompletionOnAnyPrefixChange() val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE) val search = search(prefix) - val known = mentions.filter { action -> matches(prefix, action.name, action.hints) && action.available(search) } + val rank = Ranker(prefix) + val known = mentions.filter { action -> rank.matches(action.name, action.hints) && action.available(search) } known.forEach { action -> out.addElement(prioritize(resource(action))) } if (search.indexing) { val msg = KiloBundle.message("prompt.mention.indexing") @@ -214,8 +224,37 @@ class KiloPromptCompletionProvider( } .withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE) - private fun matches(prefix: String, name: String, hints: List): Boolean = - (listOf(name) + hints).any { it.startsWith(prefix, ignoreCase = true) } + private class Ranker(prefix: String) { + private val start = matcher(prefix) + private val middle = if (prefix.any { separator(it) }) null else matcher("*$prefix") + + fun matches(name: String, hints: List): Boolean = score(name, hints) != null + + fun score(name: String, hints: List): Int? = (listOf(name) + hints).maxOfOrNull { value -> + score(value) ?: Int.MIN_VALUE + }?.takeIf { it != Int.MIN_VALUE } + + private fun score(value: String): Int? { + val exact = start.match(value) + if (exact != null) return START + start.matchingDegree(value, true, exact) + val fallback = middle ?: return null + val fuzzy = fallback.match(value) ?: return null + return fallback.matchingDegree(value, false, fuzzy) + } + + private companion object { + const val START = 10_000 + + fun matcher(prefix: String): MinusculeMatcher = NameUtil.buildMatcher(prefix) + .withMatchingMode(MatchingMode.IGNORE_CASE) + .build() + + fun separator(c: Char): Boolean = when (c) { + '_', '-', ':', '+', '.' -> true + else -> c.isWhitespace() + } + } + } private fun commandName(text: String): String? { val raw = text.trimStart() @@ -251,6 +290,11 @@ class KiloPromptCompletionProvider( private fun prioritize(element: LookupElement): LookupElement = PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100) + private fun weight(element: LookupElement, score: Int): LookupElement = + PrioritizedLookupElement.withPriority(element, score.toDouble()) + + private data class Hit(val item: LookupElement, val score: Int) + private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path) .withPresentableText("@${file.path}") .withTailText(parent(file.path), true) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index 76077daeab..b61398ca28 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -174,6 +174,69 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) } + fun `test slash completion ranks prefix matches before contains matches`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf( + CommandDto("three-jetbrains"), + CommandDto("jetbrains-one"), + CommandDto("jetbrains-two"), + ), + ) + waitFor { provider.serverCommand("/jetbrains-one") != null } + + complete("/jet") + + assertOrder("jetbrains-one", "jetbrains-two", "three-jetbrains") + } + + fun `test slash completion matches prefix across separators`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf( + CommandDto("three-jetbrains"), + CommandDto("jetbrains-one"), + CommandDto("jetbrains-two"), + CommandDto("jetbrains_two"), + CommandDto("jetbrains.three"), + CommandDto("jetbrains:four"), + CommandDto("jetbrains+five"), + ), + ) + waitFor { provider.serverCommand("/jetbrains-one") != null } + + complete("/j-") + assertEquals( + listOf("jetbrains-one", "jetbrains-two"), + matches("jetbrains-one", "jetbrains-two", "jetbrains_two", "three-jetbrains"), + ) + + complete("/j_") + assertEquals(listOf("jetbrains_two"), matches("jetbrains-one", "jetbrains_two", "three-jetbrains")) + + complete("/j.") + assertEquals(listOf("jetbrains.three"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + + complete("/j:") + assertEquals(listOf("jetbrains:four"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + + complete("/j+") + assertEquals(listOf("jetbrains+five"), matches("jetbrains.three", "jetbrains:four", "jetbrains+five")) + } + + fun `test slash completion matches camel humps with capitals`() { + rpc.state.value = KiloWorkspaceStateDto( + KiloWorkspaceStatusDto.READY, + commands = listOf(CommandDto("jetBrainsSkill"), CommandDto("jetbrains-skill")), + ) + waitFor { provider.serverCommand("/jetBrainsSkill") != null } + + complete("/JBS") + + assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "jetBrainsSkill") + assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches())) + } + fun `test blank mention completion includes special and root entries`() { rpc.searchResult = FileSearchResultDto( files = listOf(file("src", directory = true), file("README.md")), @@ -380,6 +443,19 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { private fun item(value: String) = myFixture.lookupElements.orEmpty().first { it.lookupString == value } + private fun assertOrder(vararg values: String) { + val items = myFixture.lookupElementStrings.orEmpty() + assertContainsElements(items, *values) + values.toList().zipWithNext().forEach { (left, right) -> + assertTrue("Expected $left before $right in $items", items.indexOf(left) < items.indexOf(right)) + } + } + + private fun matches(vararg values: String): List { + val items = myFixture.lookupElementStrings.orEmpty() + return values.filter { it in items } + } + private fun file(path: String, directory: Boolean = false) = WorkspaceFileDto( path = path, name = path.substringAfterLast('/'),