fix(jetbrains): tighten prompt completion routing

This commit is contained in:
kirillk
2026-06-16 21:52:48 -04:00
parent c23c3e300d
commit 1a812ea5a0
16 changed files with 229 additions and 109 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Add `/` slash commands and `@` file/git-changes mentions to the JetBrains chat prompt with native completion.
@@ -203,8 +203,6 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
}
}
override suspend fun terminalOutput(directory: String): String? = null
override suspend fun gitChanges(directory: String): String? = withContext(Dispatchers.IO) {
val base = file(clean(directory) ?: directory) ?: return@withContext null
if (!gitAvailable(base)) return@withContext null
@@ -136,15 +136,6 @@ class KiloWorkspaceService internal constructor(
}
}
suspend fun terminalOutput(directory: String): String? {
return try {
call { terminalOutput(directory) }
} catch (e: Exception) {
LOG.warn("terminal output lookup failed for directory=$directory", e)
null
}
}
suspend fun gitChanges(directory: String): String? {
return try {
call { gitChanges(directory) }
@@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService
import ai.kilocode.client.migration.MigrationUiController
import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
@@ -20,6 +21,8 @@ import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.prompt.gitChangesPart
import ai.kilocode.client.session.ui.prompt.mentionFileParts
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionDropOverlay
import ai.kilocode.client.session.ui.SessionRootPanel
@@ -47,8 +50,6 @@ import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import com.intellij.ide.BrowserUtil
@@ -67,6 +68,7 @@ import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -74,13 +76,10 @@ import java.util.function.Predicate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.event.HierarchyEvent
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import javax.swing.JComponent
import javax.swing.JPanel
@@ -171,6 +170,7 @@ class SessionUi(
private lateinit var connection: ConnectionPanel
private lateinit var prompt: PromptPanel
private lateinit var completion: KiloPromptCompletionProvider
private lateinit var load: LoadingPanel
private lateinit var migrationOverlay: MigrationOverlayPanel
private var empty: EmptySessionPanel? = null
@@ -332,7 +332,7 @@ class SessionUi(
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
connection = ConnectionPanel(this, controller)
val completion = KiloPromptCompletionProvider(
completion = KiloPromptCompletionProvider(
workspace = workspace,
service = workspaces,
actions = slashActions(),
@@ -590,7 +590,7 @@ class SessionUi(
}
prompt.clear()
val follow = scroll.atBottom()
val command = serverCommand(text)
val command = completion.serverCommand(text)
if (command != null) controller.command(command.first, command.second, files)
else controller.prompt(text, files)
scroll.followBottom(follow)
@@ -599,106 +599,49 @@ class SessionUi(
private fun slashActions(): List<KiloPromptCompletionProvider.SlashAction> = listOf(
KiloPromptCompletionProvider.SlashAction(
"new",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.new"),
KiloBundle.message("prompt.slash.new"),
) { manager?.newSession() },
KiloPromptCompletionProvider.SlashAction(
"sessions",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.sessions"),
KiloBundle.message("prompt.slash.sessions"),
listOf("history", "resume"),
) { manager?.showHistory() },
KiloPromptCompletionProvider.SlashAction(
"models",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.models"),
KiloBundle.message("prompt.slash.models"),
) { prompt.model.open() },
KiloPromptCompletionProvider.SlashAction(
"agents",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.agents"),
KiloBundle.message("prompt.slash.agents"),
listOf("modes"),
) { prompt.mode.open() },
KiloPromptCompletionProvider.SlashAction(
"variant",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.variant"),
KiloBundle.message("prompt.slash.variant"),
listOf("reasoning"),
) { prompt.reasoning.open() },
KiloPromptCompletionProvider.SlashAction(
"compact",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.compact"),
KiloBundle.message("prompt.slash.compact"),
listOf("smol"),
) { controller.compact() },
KiloPromptCompletionProvider.SlashAction(
"settings",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.settings"),
KiloBundle.message("prompt.slash.settings"),
) { openKiloSettings() },
KiloPromptCompletionProvider.SlashAction(
"help",
ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.help"),
KiloBundle.message("prompt.slash.help"),
) { BrowserUtil.browse("https://kilo.ai/docs") },
)
private fun clientCommandNames(): Set<String> = setOf(
"new",
"sessions",
"models",
"agents",
"variant",
"compact",
"settings",
"help",
)
private fun serverCommand(text: String): Pair<String, String>? {
val raw = text.trimStart()
if (!raw.startsWith('/')) return null
val name = raw.drop(1).takeWhile { !it.isWhitespace() }
if (name.isBlank()) return null
if (name in clientCommandNames()) return null
if (workspace.state.value.commands.none { it.name == name }) return null
return name to raw.drop(name.length + 1).trimStart()
}
private fun mentionParts(text: String, paths: Set<String>): List<PromptPartDto> = buildList {
paths.filter { text.contains("@$it") }.forEach { path ->
val token = "@$path"
val start = text.indexOf(token).takeIf { it >= 0 } ?: return@forEach
val target = runCatching {
val item = Path.of(path)
if (item.isAbsolute) item else Path.of(workspace.directory).resolve(item).normalize()
}.getOrNull() ?: return@forEach
add(PromptPartDto(
type = "file",
mime = "text/plain",
url = target.toUri().toString(),
filename = target.fileName?.toString(),
source = source("file", token, start, path = path),
))
}
val terminal = text.indexOf("@terminal")
if (terminal >= 0) {
runBlocking { workspaces.terminalOutput(workspace.directory) }
?.takeIf { it.isNotBlank() }
?.let { add(dataPart("terminal-output.txt", it, source("resource", "@terminal", terminal, uri = "terminal"))) }
}
val git = text.indexOf("@git-changes")
if (git >= 0) {
runBlocking { workspaces.gitChanges(workspace.directory) }
?.takeIf { it.isNotBlank() }
?.let { add(dataPart("git-changes.txt", it, source("resource", "@git-changes", git, uri = "git-changes"))) }
addAll(mentionFileParts(text, paths, workspace.directory))
if (text.contains("@git-changes")) {
gitChangesPart(text, runBlockingCancellable { workspaces.gitChanges(workspace.directory) })?.let(::add)
}
}
private fun dataPart(name: String, text: String, source: PartSourceDto? = null): PromptPartDto {
val data = URLEncoder.encode(text, StandardCharsets.UTF_8).replace("+", "%20")
return PromptPartDto(type = "file", mime = "text/plain", url = "data:text/plain;charset=utf-8,$data", filename = name, source = source)
}
private fun source(type: String, token: String, start: Int, path: String? = null, uri: String? = null) = PartSourceDto(
type = type,
text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()),
path = path,
uri = uri,
clientName = if (type == "resource") "jetbrains" else null,
)
private fun openFile(path: String) {
cs.launch {
workspaces.openPath(workspace.directory, path)
@@ -51,6 +51,16 @@ class KiloPromptCompletionProvider(
fun clientNames(): Set<String> = actions.mapTo(mutableSetOf()) { it.name }
fun serverCommand(text: String): Pair<String, String>? {
val raw = text.trimStart()
if (!raw.startsWith('/')) return null
val name = raw.drop(1).takeWhile { !it.isWhitespace() }
if (name.isBlank()) return null
if (name in clientNames()) return null
if (workspace.state.value.commands.none { it.name == name }) return null
return name to raw.drop(name.length + 1).trimStart()
}
fun highlights(text: String): List<Highlight> = buildList {
val command = text.takeIf { it.startsWith('/') }
?.drop(1)
@@ -62,7 +72,7 @@ class KiloPromptCompletionProvider(
}
val ranges = mutableListOf<IntRange>()
val values = (mentionPaths() + setOf("terminal", "git-changes"))
val values = (mentionPaths() + setOf("git-changes"))
.filter { it.isNotBlank() }
.sortedByDescending { it.length }
values.forEach { value ->
@@ -118,9 +128,6 @@ class KiloPromptCompletionProvider(
if ("git-changes".startsWith(prefix, ignoreCase = true) && search.git) {
out.addElement(special("git-changes", KiloBundle.message("prompt.mention.gitChanges")))
}
if ("terminal".startsWith(prefix, ignoreCase = true) && search.terminal) {
out.addElement(special("terminal", KiloBundle.message("prompt.mention.terminal")))
}
if (search.indexing) {
val msg = KiloBundle.message("prompt.mention.indexing")
result.addLookupAdvertisement(msg)
@@ -0,0 +1,49 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import ai.kilocode.rpc.dto.PromptPartDto
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
fun mentionFileParts(text: String, paths: Set<String>, directory: String): List<PromptPartDto> = buildList {
paths.filter { text.contains("@$it") }.forEach { path ->
val token = "@$path"
val start = text.indexOf(token).takeIf { it >= 0 } ?: return@forEach
val target = runCatching {
val item = Path.of(path)
if (item.isAbsolute) item else Path.of(directory).resolve(item).normalize()
}.getOrNull() ?: return@forEach
add(PromptPartDto(
type = "file",
mime = "text/plain",
url = target.toUri().toString(),
filename = target.fileName?.toString(),
source = source("file", token, start, path = path),
))
}
}
fun gitChangesPart(text: String, diff: String?): PromptPartDto? {
val raw = "@git-changes"
val start = text.indexOf(raw)
if (start < 0) return null
val end = start + raw.length
if (end < text.length && !text[end].isWhitespace()) return null
val value = diff?.takeIf { it.isNotBlank() } ?: return null
return dataPart("git-changes.txt", value, source("resource", raw, start, uri = "git-changes"))
}
private fun dataPart(name: String, text: String, source: PartSourceDto? = null): PromptPartDto {
val data = URLEncoder.encode(text, StandardCharsets.UTF_8).replace("+", "%20")
return PromptPartDto(type = "file", mime = "text/plain", url = "data:text/plain;charset=utf-8,$data", filename = name, source = source)
}
private fun source(type: String, token: String, start: Int, path: String? = null, uri: String? = null) = PartSourceDto(
type = type,
text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()),
path = path,
uri = uri,
clientName = if (type == "resource") "jetbrains" else null,
)
@@ -512,6 +512,7 @@ class PromptPanel(
}
private fun showCompletion(ed: com.intellij.openapi.editor.Editor) {
// Uses IntelliJ impl/internal completion APIs; revisit on platform upgrades.
CodeCompletionHandlerBase.createHandler(CompletionType.BASIC, true, false, true)
.invokeCompletion(project, ed, 1)
val lookup = LookupManager.getActiveLookup(ed) as? LookupImpl ?: return
@@ -163,7 +163,6 @@ prompt.action.enhance.failed=Failed to enhance prompt
prompt.action.enhance.failed.description=The configured small model could not enhance this prompt.
prompt.mention.indexing=Indexing project files. File mentions will be available soon.
prompt.mention.gitChanges=Attach current git changes
prompt.mention.terminal=Attach terminal output
prompt.slash.new=Start a new session
prompt.slash.sessions=Open session history
prompt.slash.models=Choose a model
@@ -0,0 +1,69 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
class CommandLifecycleTest : SessionControllerTestBase() {
fun `test command creates new session and calls RPC`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy")))
val m = controller()
flush()
edt { m.command("deploy", "prod") }
flush()
assertEquals(1, rpc.creates)
assertEquals(1, rpc.commands.size)
val call = rpc.commands.single()
assertEquals("ses_test", call.id)
assertEquals("/test", call.directory)
assertEquals("deploy", call.command)
assertEquals("prod", call.arguments)
}
fun `test command reuses existing session`() {
val (m, _, _) = prompted()
val created = rpc.creates
edt { m.command("deploy", "prod") }
flush()
assertEquals(created, rpc.creates)
assertEquals("ses_test", rpc.commands.single().id)
}
fun `test command records telemetry`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy")))
val m = controller()
flush()
edt { m.command("deploy", "prod") }
flush()
val sent = appRpc.telemetry.single { it.event == "Conversation Send Clicked" }
assertEquals("command", sent.properties["source"])
val message = appRpc.telemetry.single { it.event == "Conversation Message" }
assertEquals("command", message.properties["source"])
}
fun `test command errors set state and telemetry`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy")))
rpc.commandThrows = IllegalStateException("boom")
val m = controller()
flush()
edt { m.command("deploy", "prod") }
flush()
assertTrue(m.model.state is SessionState.Error)
val event = appRpc.telemetry.single { it.event == "Session Error" }
assertEquals("command", event.properties["context"])
}
}
@@ -221,12 +221,12 @@ class PromptPanelTest : BasePlatformTestCase() {
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "/new use @terminal and @unknown"
field.text = "/new use @git-changes and @unknown"
UIUtil.dispatchAllInvocationEvents()
val spans = spans(field)
assertTrue(spans.contains("/new" to DefaultLanguageHighlighterColors.KEYWORD))
assertTrue(spans.contains("@terminal" to DefaultLanguageHighlighterColors.METADATA))
assertTrue(spans.contains("@git-changes" to DefaultLanguageHighlighterColors.METADATA))
assertFalse(spans.any { it.first == "@unknown" })
}
@@ -235,7 +235,7 @@ class PromptPanelTest : BasePlatformTestCase() {
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "use @terminal"
field.text = "use @git-changes"
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, field.getEditor(false)!!.markupModel.allHighlighters.size)
@@ -251,7 +251,7 @@ class PromptPanelTest : BasePlatformTestCase() {
realize(panel, 260, 400)
repeat(50) {
field.text = if (it % 2 == 0) "/new @terminal" else "/new @git-changes"
field.text = if (it % 2 == 0) "/new @git-changes" else "/new @git-changes now"
UIUtil.dispatchAllInvocationEvents()
assertTrue(field.getEditor(false)!!.markupModel.allHighlighters.size <= 2)
}
@@ -69,7 +69,7 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
}
fun `test mention completion includes matching special items`() {
rpc.searchResult = FileSearchResultDto(git = true, terminal = true)
rpc.searchResult = FileSearchResultDto(git = true)
complete("@git<caret>")
@@ -103,13 +103,23 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
fun `test highlights special mentions without tracked paths`() {
assertEquals(
listOf(
KiloPromptCompletionProvider.Highlight(4, 13, KiloPromptCompletionProvider.HighlightKind.MENTION),
KiloPromptCompletionProvider.Highlight(18, 30, KiloPromptCompletionProvider.HighlightKind.MENTION),
KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.MENTION),
),
provider.highlights("use @terminal and @git-changes").sortedBy { it.start },
provider.highlights("use @git-changes").sortedBy { it.start },
)
}
fun `test serverCommand routes only known server commands`() {
rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("deploy")))
waitFor { provider.serverCommand("/deploy x") != null }
assertEquals("deploy" to "x", provider.serverCommand("/deploy x"))
assertNull(provider.serverCommand("/new"))
assertNull(provider.serverCommand("hi /deploy"))
assertNull(provider.serverCommand("/unknown"))
}
fun `test highlights tracked mentions longest first`() {
addMention("src/a.ts", "@ts")
addMention("src/a.tsx", "@tsx")
@@ -0,0 +1,56 @@
package ai.kilocode.client.session.ui.prompt
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.nio.file.Path
class PromptMentionPartsTest : BasePlatformTestCase() {
fun `test mentionFileParts builds file part for tracked relative path`() {
val parts = mentionFileParts("read @src/Main.kt", setOf("src/Main.kt"), "/repo")
assertEquals(1, parts.size)
val part = parts.single()
assertEquals("file", part.type)
assertEquals("text/plain", part.mime)
assertEquals(Path.of("/repo/src/Main.kt").toUri().toString(), part.url)
assertEquals("Main.kt", part.filename)
assertEquals("file", part.source?.type)
assertEquals("src/Main.kt", part.source?.path)
assertEquals("@src/Main.kt", part.source?.text?.value)
assertEquals(5.0, part.source?.text?.start)
assertEquals(17.0, part.source?.text?.end)
}
fun `test mentionFileParts ignores untracked path`() {
assertTrue(mentionFileParts("read @src/Main.kt", setOf("src/Other.kt"), "/repo").isEmpty())
}
fun `test mentionFileParts keeps absolute paths absolute`() {
val path = "/tmp/abs.txt"
val part = mentionFileParts("read @$path", setOf(path), "/repo").single()
assertEquals(Path.of(path).toUri().toString(), part.url)
assertEquals("abs.txt", part.filename)
}
fun `test gitChangesPart builds encoded data part`() {
val part = gitChangesPart("review @git-changes", "hello world+plus")!!
assertEquals("file", part.type)
assertEquals("text/plain", part.mime)
assertEquals("git-changes.txt", part.filename)
assertEquals("data:text/plain;charset=utf-8,hello%20world%2Bplus", part.url)
assertEquals("resource", part.source?.type)
assertEquals("git-changes", part.source?.uri)
assertEquals("jetbrains", part.source?.clientName)
assertEquals("@git-changes", part.source?.text?.value)
assertEquals(7.0, part.source?.text?.start)
assertEquals(19.0, part.source?.text?.end)
}
fun `test gitChangesPart ignores missing blank and non boundary matches`() {
assertNull(gitChangesPart("review @git-changes", null))
assertNull(gitChangesPart("review @git-changes", " "))
assertNull(gitChangesPart("review @git-changes-foo", "diff"))
}
}
@@ -84,6 +84,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
var enhanced = "Enhanced prompt"
var enhanceGate: CompletableDeferred<Unit>? = null
var enhanceThrows: Exception? = null
var commandThrows: Exception? = null
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
val commands = mutableListOf<CommandCall>()
val attachmentParts = mutableListOf<AttachmentCall>()
@@ -199,6 +200,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
override suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) {
assertNotEdt("command")
commandThrows?.let { throw it }
commands.add(CommandCall(id, directory, command, arguments, prompt))
}
@@ -30,7 +30,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
var fileMatches = emptyList<WorkspaceFileDto>()
var searchResult = FileSearchResultDto()
var search: ((String) -> FileSearchResultDto)? = null
var terminalOutput: String? = null
var gitChanges: String? = null
var openResult = true
var localConfigPath = "/test/.kilo/kilo.jsonc"
@@ -82,11 +81,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return search?.invoke(query) ?: searchResult
}
override suspend fun terminalOutput(directory: String): String? {
assertNotEdt("terminalOutput")
return terminalOutput
}
override suspend fun gitChanges(directory: String): String? {
assertNotEdt("gitChanges")
return gitChanges
@@ -50,9 +50,6 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
/** Fuzzy file/folder search via the backend IDE index. */
suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto
/** Best-effort active terminal scrollback for @terminal mentions. */
suspend fun terminalOutput(directory: String): String?
/** Current uncommitted git changes as a unified diff for @git-changes mentions. */
suspend fun gitChanges(directory: String): String?
@@ -13,6 +13,5 @@ data class WorkspaceFileDto(
data class FileSearchResultDto(
val indexing: Boolean = false,
val files: List<WorkspaceFileDto> = emptyList(),
val terminal: Boolean = false,
val git: Boolean = false,
)