fix(jetbrains): stabilize prompt mention completion

This commit is contained in:
kirillk
2026-06-18 15:56:40 -04:00
parent 84173644d3
commit c746eea1ee
7 changed files with 652 additions and 51 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains file mention suggestions immediately for empty `@` mentions and keep the completion popup stable while typing quickly.
@@ -0,0 +1,243 @@
# Make JetBrains `@`/`/` prompt completion as reliable as editor completion
Scope: Kilo-owned frontend code under
`packages/kilo-jetbrains/frontend/.../session/ui/prompt/`. No shared/upstream files, so
no `kilocode_change` markers needed.
This is a follow-up to `jetbrains-empty-mention-completion.md` (prewarm +
`ALWAYS_AUTO_POPUP` removal, already implemented). It fixes the remaining instability.
## Symptoms (from user)
- Open `@`, then **type fast** (e.g. `@backend`) → the lookup **disappears**.
- **Type slowly** → it stays and narrows fine.
- The popup also **flickers** and **shows below the caret for a moment** before settling
above.
## Root cause (verified against `$INTELLIJ_REPO`)
The provider fetches results **asynchronously on its own coroutine scope** and then forces
the open lookup to repaint via a **manual restart**:
- `KiloPromptCompletionProvider.search()` (`KiloPromptCompletionProvider.kt:198-207`)
returns an **empty placeholder** immediately for a cold prefix and launches a debounced
`refresh()` (`:215-228`).
- When `refresh()` finishes it stores `cache[prefix]` and posts `onRefresh()`
(`:224-225`) → `PromptPanel.restartCompletion()` (`PromptPanel.kt:561-569`) →
`CompletionProgressIndicator.scheduleRestart()`.
`scheduleRestart()` does not repaint in place. It `cancel()`s the current indicator and
schedules a brand-new completion through the `CommittingDocuments` phase
(`CompletionProgressIndicator.java:913-941``CompletionPhase.kt:234-262`). That async
path commits documents in a non-blocking read action and then, on the EDT, runs
`startAsyncCompletionIfNotExpired` (`CompletionPhase.kt:294-340`). It contains:
```kotlin
if (phase.myTracker.hasAnythingHappened() && (phase.indicator == null || !phase.indicator.lookup.isShown)) {
phase.cancelPhase()
CompletionServiceImpl.setCompletionPhase(NoCompletion) // <- popup disappears
return
}
```
So when our async `onRefresh → scheduleRestart` lands **while the user is still typing**
(`ActionTracker.hasAnythingHappened()` is true) and the lookup is in a transient
not-`isShown` window (it is, because our cold-prefix path produced only the transient
"Searching files…" element), the phase is dropped to `NoCompletion` and the lookup is
gone. Slow typing avoids the overlap, which is why it only reproduces under fast typing.
Why the editor's own completion does **not** disappear under fast typing: every keystroke
restart goes through the same code, but the editor's contributors return their items
**synchronously inside the background completion calculation**, so the lookup is already
`isShown` with real items and the `hasAnythingHappened()` branch is skipped (the comment
on `CompletionPhase.kt:327-328` says a shown lookup "is going to handle close by itself").
Our extra, arbitrarily-timed manual restart is the thing that races.
The "shows below then jumps above" flicker is a related but separate timing bug:
`showCompletion` sets `LookupPositionStrategy.ONLY_ABOVE` **after** `invokeCompletion`
returns (`PromptPanel.kt:555-558`), i.e. after the lookup has already been created and
positioned at the default `PREFER_BELOW` (`LookupPresentation.kt:24`). The disappear →
recreate cycle makes this visible repeatedly.
### Key platform facts that make the fix safe
1. **Completion contributors run off the EDT, on a background thread, inside a cancellable
read action.** `CodeCompletionHandlerBase.startContributorThread`
(`CodeCompletionHandlerBase.java:414-438`) runs `indicator.runContributors` via
`AsyncCompletion.startThread` (`CompletionThreading.kt:118-153`,
`Dispatchers.IO.limitedParallelism(5)`). The indicator picks `AsyncCompletion` whenever
completion is not invoked inside a write action
(`CompletionProgressIndicator.java:200-202`). Therefore a **blocking** `searchFiles`
call inside `fillCompletionVariants`, guarded by `runBlockingCancellable`, is the
platform-intended pattern (same as index-backed file/class completion) — it does not
touch the EDT, and `FakeWorkspaceRpcApi.searchFiles` already asserts not-EDT, which the
existing unit-test path satisfies today via `fetch()`.
2. **The platform already drives "background updates" of an open lookup.** While the
contributor runs, the lookup shows the native calculating state and the
`CompletionConsumer` adds items to the live lookup as they are produced. This is the
mechanism to "reuse" — we do not need our own debounce/refresh/scheduleRestart.
3. **Restart reuses the same lookup instance.** `obtainLookup`
(`CodeCompletionHandlerBase.java:274-298`) returns the existing completion lookup via
`markReused()`, so a `LookupPresentation` set once persists across prefix-change
restarts.
4. **The lookup is activated before it is shown.** `ClientLookupManager.createLookup`
fires `LookupManagerListener.activeLookupChanged(null, lookup)`
(`ClientLookupManager.kt:91-93`) before `indicator.showLookup()`. Setting the position
strategy in that listener positions the lookup correctly on first paint — no flash.
5. `restartCompletionOnAnyPrefixChange()` (`KiloPromptCompletionProvider.kt:160,174`) is
what makes the platform re-run our contributor (and thus a fresh search) for each new
prefix. Keep it; it is the supported per-keystroke restart trigger.
## Plan
### Part 1 — Compute results synchronously on the completion background thread
Make the production path do what the unit-test path already does: block on the backend
search (with cancellation) inside `fillCompletionVariants`, and let the platform manage
the lookup. Delete the async refresh + manual restart entirely.
In `KiloPromptCompletionProvider.kt`:
- **Replace `search()`** (`:198-207`) with a cache-or-fetch that always returns a settled
result:
```kotlin
private fun search(prefix: String): FileSearchResultDto = cache[prefix] ?: fetch(prefix)
```
`fetch()` (`:209-213`) stays as-is: `runBlockingCancellable { service.searchFiles(...) }`
+ `cache[prefix] = result`. It now runs on the async completion thread in production too.
- **Delete** `refresh()` (`:215-228`), the `onRefresh` field (`:48-49`), the `job`/`want`
`@Volatile` fields (`:42-46`), and `SEARCH_DELAY_MS` (`:51-53`).
- **Delete** the `Search` data class (`:322`) and the `pending` plumbing. Simplify
`mention()` (`:173-196`): drop the `search.pending` branch and the
`prompt.mention.searching` placeholder; keep the `indexing` branch and the `noMatches`
fallback. The native lookup spinner now covers the "in flight" state.
- **`clearMentions()`** (`:68-76`): keep clearing `paths/exists/pending/cache`; drop
`want`/`job` resets.
- Remove now-unused imports: `kotlinx.coroutines.Job`, `kotlinx.coroutines.delay`,
`com.intellij.openapi.application.ApplicationManager` (was only used for the
unit-test-mode branch and the `invokeLater` in `refresh`). Keep `launch`/`CoroutineScope`
(used by `prewarm` and `validate`) and `runBlockingCancellable`.
- Keep `prewarm()` and `restartCompletionOnAnyPrefixChange()` unchanged. Prewarm keeps the
first `@` instant (cache hit, no blocking); per-prefix restarts hit the cache when the
prefix repeats and otherwise block briefly with native cancellation.
In `PromptPanel.kt`:
- **Delete** the `completion?.onRefresh = { ... restartCompletion() }` wiring in `init`
(`:250-252`), the `completion?.onRefresh = null` line in `removeNotify` (`:451`), and the
`restartCompletion()` method (`:561-569`).
- Remove the now-unused import
`com.intellij.codeInsight.completion.impl.CompletionServiceImpl` (`:25`).
Remove the unused `prompt.mention.searching` key from
`frontend/src/main/resources/messages/KiloBundle.properties:167` (no locale file
references it). Keep `prompt.mention.indexing`.
### Part 2 — Position the lookup above the caret before first paint
Set `ONLY_ABOVE` as soon as the completion lookup for our editor is created, via a
project-level `LookupManagerListener`, instead of after `invokeCompletion`.
In `PromptPanel.kt`:
- Add a `private var lookupBus: MessageBusConnection? = null` and a `bindLookup()` that
subscribes once to `LookupManagerListener.TOPIC` on `project.messageBus`:
```kotlin
connection.subscribe(LookupManagerListener.TOPIC, LookupManagerListener { _, next ->
val lookup = next as? LookupImpl ?: return@LookupManagerListener
if (lookup.editor !== editor.getEditor(false)) return@LookupManagerListener
lookup.presentation = LookupPresentation.Builder(lookup.presentation)
.withPositionStrategy(LookupPositionStrategy.ONLY_ABOVE)
.build()
})
```
Call `bindLookup()` from `addNotify()` (next to `bindRoot()`/`bindKeymap()`); disconnect
in `removeNotify()` (`lookupBus?.disconnect(); lookupBus = null`).
- **Simplify `showCompletion()`** (`:551-559`) to just open completion
(`invokeCompletion(project, ed, 1)`); drop the post-invoke presentation block — the
listener now owns positioning for both the `@`/`/` document-triggered open and the
completion-shortcut open, and it persists across restarts (reused lookup).
- Add import `com.intellij.codeInsight.lookup.LookupManagerListener`. Existing
`LookupManager`, `LookupImpl`, `LookupPresentation`, `LookupPositionStrategy` imports stay
(now used by the listener).
## Tests
`KiloPromptCompletionProviderTest` (provider built directly; `myFixture.completeBasic()`
runs the real off-EDT completion calculation):
- All existing tests stay green. They already exercise the blocking `fetch()` path (the old
`isUnitTestMode` branch) and assert exact `rpc.searchQueries`; `completeBasic()` issues a
single search per invocation, so query counts are unchanged. `test prewarm serves blank
mention completion from cache` and `test mention completion reuses identical prefix
result` still hold (cache hit ⇒ no extra query); `test clearing mentions resets cached
prefix result` still holds (cache cleared ⇒ refetch).
- The `prompt.mention.searching` placeholder is no longer produced; confirm no test asserts
it (none do — only `noMatches()` and `indexing` are asserted/relevant).
`PromptPanelTest` (real editor + lookup via `invokeCompletionAction` + `waitForLookupItems`):
- Existing `test prompt local completion shortcut opens mention lookup` / `... slash lookup`
stay green and now cover the production blocking path end to end.
- **Add** `test prompt completion lookup is positioned above caret`: open the mention
lookup as in the existing shortcut test, then assert
`(LookupManager.getActiveLookup(editor) as LookupImpl).presentation.positionStrategy ==
LookupPositionStrategy.ONLY_ABOVE`.
Note on the disappear/flicker race: it is an EDT/timing interaction that cannot be asserted
deterministically in a unit test. Coverage is the deterministic position test above plus
the existing real-lookup tests; the fast-typing regression is validated manually in
`./gradlew runIde`.
## Files to change
- `frontend/.../session/ui/prompt/KiloPromptCompletionProvider.kt` — blocking
`search()`; delete `refresh`/`onRefresh`/`job`/`want`/`SEARCH_DELAY_MS`/`Search`/pending;
simplify `mention()`; trim imports.
- `frontend/.../session/ui/prompt/PromptPanel.kt` — delete `onRefresh` wiring and
`restartCompletion()`; add `LookupManagerListener` for `ONLY_ABOVE`; simplify
`showCompletion()`; adjust imports.
- `frontend/src/main/resources/messages/KiloBundle.properties` — remove
`prompt.mention.searching`.
- `frontend/src/test/.../prompt/KiloPromptCompletionProviderTest.kt` /
`.../session/ui/PromptPanelTest.kt` — keep existing green; add position test.
## Verification
From `packages/kilo-jetbrains/`:
- `./gradlew :frontend:test --tests
ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProviderTest --tests
ai.kilocode.client.session.ui.PromptPanelTest`
- `./gradlew typecheck`
- Manual (`./gradlew runIde`): open a session, `@` then type `@backend` **fast** → lookup
stays open and narrows (no disappear); it opens **above** the caret with no below-flash;
`/` commands behave the same; send a prompt and repeat.
## Decisions / tradeoffs / risks
1. **Block on the background completion thread; drop the manual restart.** This is the
idiomatic platform pattern and removes the `onRefresh → scheduleRestart` race that
causes the disappear. It also removes our 120 ms debounce and the dual code paths
(test vs prod), unifying behavior.
2. **Chatty-RPC tradeoff.** Each prefix change re-runs the search. In practice superseded
calculations are cancelled by the platform (`runBlockingCancellable` throws on cancel,
`searchFiles` rethrows `CancellationException`), the per-prefix `cache` dedupes repeats,
and `prewarm` covers the empty prefix. Backend file search is cheap, so this is
acceptable and matches how index-backed completion behaves.
3. **Read lock held during the search.** The contributor runs inside a cancellable read
action; a localhost search blocks it briefly. `BgCalculation.restartOnWriteAction`
(`CompletionPhase.kt:446-470`) cancels the indicator the moment the user types (write
action), promptly releasing the lock. This is the same contract index-backed
contributors rely on.
4. **Indexing/transient results.** With no auto-refresh, if the backend reports `indexing`
the user sees the "Indexing…" message until the next keystroke or reopen re-queries
(rare, brief). `prewarm` already avoids pinning indexing/empty results in `cache[""]`.
5. **Experimental/internal APIs.** `LookupPresentation` / `LookupPositionStrategy` are
`@ApiStatus.Experimental` and `LookupImpl` is impl-level — already used here. The new
`LookupManagerListener` is a stable public topic. Revisit on platform upgrades (the
existing `showCompletion`/`restartCompletion` comments already flag this).
@@ -8,10 +8,10 @@ import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.InsertHandler
import com.intellij.codeInsight.completion.InsertionContext
import com.intellij.codeInsight.completion.PlainPrefixMatcher
import com.intellij.codeInsight.completion.PrioritizedLookupElement
import com.intellij.codeInsight.lookup.AutoCompletionPolicy
import com.intellij.codeInsight.lookup.CharFilter
import com.intellij.codeInsight.lookup.LookupElement
import com.intellij.codeInsight.lookup.LookupElementBuilder
@@ -21,17 +21,21 @@ import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.util.textCompletion.TextCompletionProvider
import java.util.concurrent.ConcurrentHashMap
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class KiloPromptCompletionProvider(
private val workspace: Workspace,
private val service: KiloWorkspaceService,
private val actions: List<SlashAction>,
private val scope: CoroutineScope,
) : TextCompletionProvider, DumbAware {
private val paths = Collections.synchronizedSet(mutableSetOf<String>())
@Volatile
private var cached: Pair<String, FileSearchResultDto>? = null
private val exists = Collections.synchronizedMap(mutableMapOf<String, Boolean>())
private val pending = Collections.synchronizedSet(mutableSetOf<String>())
private val cache = ConcurrentHashMap<String, FileSearchResultDto>()
data class SlashAction(
val name: String,
@@ -42,15 +46,27 @@ class KiloPromptCompletionProvider(
data class Highlight(val start: Int, val end: Int, val kind: HighlightKind)
enum class HighlightKind { MENTION, COMMAND }
enum class HighlightKind { MENTION, COMMAND, INVALID }
fun mentionPaths(): Set<String> = paths.toSet()
fun clearMentions() {
paths.clear()
cached = null
exists.clear()
pending.clear()
cache.clear()
}
fun prewarm() {
if (cache.containsKey("")) return
scope.launch {
val result = service.searchFiles(workspace.directory, "", 50)
if (result.files.isNotEmpty() || result.git) cache.putIfAbsent("", result)
}
}
fun inside(text: String, caret: Int): Boolean = mentionSpans(text).any { span -> caret in span.start..span.end }
fun clientNames(): Set<String> = actions.mapTo(mutableSetOf()) { it.name }
fun serverCommand(text: String): Pair<String, String>? {
@@ -63,7 +79,7 @@ class KiloPromptCompletionProvider(
return name to raw.drop(name.length + 1).trimStart()
}
fun highlights(text: String): List<Highlight> = buildList {
fun highlights(text: String, caret: Int = -1): List<Highlight> = buildList {
val command = text.takeIf { it.startsWith('/') }
?.drop(1)
?.takeWhile { !it.isWhitespace() }
@@ -73,22 +89,30 @@ class KiloPromptCompletionProvider(
add(Highlight(0, command.length + 1, HighlightKind.COMMAND))
}
val ranges = mutableListOf<IntRange>()
val values = (mentionPaths() + setOf("git-changes"))
.filter { it.isNotBlank() }
.sortedByDescending { it.length }
values.forEach { value ->
val raw = "@$value"
var idx = text.indexOf(raw)
while (idx >= 0) {
val end = idx + raw.length
val valid = end == text.length || text[end].isWhitespace()
val range = idx until end
if (valid && ranges.none { it.first < end && idx < it.last + 1 }) {
ranges += range
add(Highlight(idx, end, HighlightKind.MENTION))
}
idx = text.indexOf(raw, idx + 1)
mentionSpans(text).forEach { span ->
val under = caret in span.start..span.end
when {
span.value == "git-changes" -> add(Highlight(span.start, span.end, HighlightKind.MENTION))
span.value in paths || exists[span.value] == true -> add(Highlight(span.start, span.end, HighlightKind.MENTION))
under -> Unit
exists[span.value] == false -> add(Highlight(span.start, span.end, HighlightKind.INVALID))
}
}
}
fun validate(text: String, caret: Int, onResolved: () -> Unit) {
mentionSpans(text).forEach { span ->
val value = span.value
if (value == "git-changes") return@forEach
if (value in paths) return@forEach
if (caret in span.start..span.end) return@forEach
if (exists.containsKey(value)) return@forEach
if (!pending.add(value)) return@forEach
scope.launch {
val ok = runCatching { service.files(workspace.directory, value).isNotEmpty() }.getOrDefault(false)
exists[value] = ok
pending.remove(value)
onResolved()
}
}
}
@@ -115,40 +139,64 @@ class KiloPromptCompletionProvider(
}
private fun slash(prefix: String, result: CompletionResultSet) {
result.restartCompletionOnAnyPrefixChange()
val out = applyPrefixMatcher(result, prefix)
val names = clientNames()
actions.forEach { action -> out.addElement(client(action)) }
workspace.state.value.commands
.filter { it.name !in names }
.forEach { command -> out.addElement(server(command)) }
if (actions.any { matches(prefix, it.name, it.hints) }) return
if (workspace.state.value.commands.any { it.name !in names && matches(prefix, it.name, it.hints) }) return
result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE)
.addElement(info(prefix, KiloBundle.message("prompt.completion.noMatches")))
}
private fun mention(prefix: String, result: CompletionResultSet) {
result.restartCompletionOnAnyPrefixChange()
val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE)
val search = search(prefix)
if ("git-changes".startsWith(prefix, ignoreCase = true) && search.git) {
val git = "git-changes".startsWith(prefix, ignoreCase = true) && search.git
if (git) {
out.addElement(prioritize(special("git-changes", KiloBundle.message("prompt.mention.gitChanges"))))
}
if (search.indexing) {
val msg = KiloBundle.message("prompt.mention.indexing")
result.addLookupAdvertisement(msg)
out.addElement(LookupElementBuilder.create(msg)
.withPresentableText(msg)
.withIcon(AllIcons.General.Information)
.withInsertHandler { _, _ -> })
out.addElement(info(prefix, msg))
return
}
search.files.forEach { file -> out.addElement(file(file)) }
if (!git && search.files.isEmpty()) {
val msg = KiloBundle.message("prompt.completion.noMatches")
out.addElement(info(prefix, msg))
}
}
private fun search(prefix: String): FileSearchResultDto {
cached?.takeIf { it.first == prefix }?.let { return it.second }
private fun search(prefix: String): FileSearchResultDto = cache[prefix] ?: fetch(prefix)
private fun fetch(prefix: String): FileSearchResultDto {
val result = runBlockingCancellable { service.searchFiles(workspace.directory, prefix, 50) }
cached = prefix to result
cache[prefix] = result
return result
}
private fun info(prefix: String, msg: String): LookupElement = LookupElementBuilder.create(msg)
.withPresentableText(msg)
.withIcon(AllIcons.General.Information)
.withInsertHandler { ctx, _ ->
val start = (ctx.startOffset - prefix.length).coerceAtLeast(0)
val tail = ctx.tailOffset.coerceAtMost(ctx.document.textLength)
val end = (tail until ctx.document.textLength).firstOrNull { ctx.document.text[it].isWhitespace() }
?: ctx.document.textLength
ctx.document.replaceString(start, end, prefix)
ctx.editor.caretModel.moveToOffset(start + prefix.length)
}
.withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE)
private fun matches(prefix: String, name: String, hints: List<String>): Boolean =
(listOf(name) + hints).any { it.startsWith(prefix, ignoreCase = true) }
private fun client(action: SlashAction): LookupElement = LookupElementBuilder.create(action.name)
.withPresentableText("/${action.name}")
.withTailText(" ${action.description}", true)
@@ -192,14 +240,20 @@ class KiloPromptCompletionProvider(
val text = ctx.document.text
val offset = ctx.startOffset.coerceAtMost(text.length)
val start = (offset - 1 downTo 0).firstOrNull { text[it].isWhitespace() }?.plus(1) ?: 0
val end = ctx.tailOffset.coerceAtMost(text.length)
val end = tokenEnd(text, start)
val next = text.getOrNull(end)
val insert = if (trim && next?.isWhitespace() == true) value.trimEnd() else value
path?.let {
paths.add(it)
exists[it] = true
}
ctx.document.replaceString(start, end, insert)
ctx.editor.caretModel.moveToOffset(start + insert.length)
path?.let(paths::add)
}
private fun tokenEnd(text: String, start: Int): Int =
(start until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length
private fun parent(path: String): String {
val idx = path.lastIndexOf('/')
if (idx <= 0) return ""
@@ -207,15 +261,29 @@ class KiloPromptCompletionProvider(
}
private fun token(text: String, offset: Int): Token? {
val head = text.take(offset.coerceIn(0, text.length))
val start = (head.length - 1 downTo 0).firstOrNull { head[it].isWhitespace() }?.plus(1) ?: 0
val raw = head.substring(start)
if (raw.startsWith("/") && head.take(start).isBlank() && raw.indexOf(' ') < 0) return Token(Kind.SLASH, raw.drop(1))
if (raw.startsWith("@") && raw.indexOf(' ') < 0) return Token(Kind.MENTION, raw.drop(1))
val pos = offset.coerceIn(0, text.length)
val start = (pos - 1 downTo 0).firstOrNull { text[it].isWhitespace() }?.plus(1) ?: 0
val end = (pos until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length
val head = text.substring(start, pos)
val raw = text.substring(start, end)
if (raw.startsWith("/") && text.take(start).isBlank() && raw.indexOf(' ') < 0) return Token(Kind.SLASH, head.drop(1))
if (raw.startsWith("@") && raw.indexOf(' ') < 0) return Token(Kind.MENTION, head.drop(1))
return null
}
private data class Token(val kind: Kind, val prefix: String)
private data class Span(val start: Int, val end: Int, val value: String)
private fun mentionSpans(text: String): List<Span> = buildList {
var pos = 0
while (pos < text.length) {
val start = (pos until text.length).firstOrNull { !text[it].isWhitespace() } ?: return@buildList
val end = tokenEnd(text, start)
if (text[start] == '@' && end > start + 1) add(Span(start, end, text.substring(start + 1, end)))
pos = end + 1
}
}
private enum class Kind { SLASH, MENTION }
}
@@ -20,10 +20,9 @@ import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.PromptPartDto
import com.intellij.icons.AllIcons
import com.intellij.codeInsight.AutoPopupController
import com.intellij.codeInsight.completion.CodeCompletionHandlerBase
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupManager
import com.intellij.codeInsight.lookup.LookupManagerListener
import com.intellij.codeInsight.lookup.LookupPositionStrategy
import com.intellij.codeInsight.lookup.LookupPresentation
import com.intellij.codeInsight.lookup.impl.LookupImpl
@@ -42,7 +41,10 @@ import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.editor.event.CaretEvent
import com.intellij.openapi.editor.event.CaretListener
import com.intellij.openapi.editor.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.editor.markup.HighlighterLayer
@@ -108,6 +110,7 @@ class PromptPanel(
private val WAND_ICON: Icon = IconLoader.getIcon("/icons/wand-sparkles.svg", PromptPanel::class.java)
private val MENTION_KEY = DefaultLanguageHighlighterColors.METADATA
private val COMMAND_KEY = DefaultLanguageHighlighterColors.KEYWORD
private val INVALID_KEY = CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES
private const val COMPLETION_ACTION_TEXT = "Kilo Prompt Completion"
}
@@ -134,8 +137,10 @@ class PromptPanel(
private val highlighters = mutableListOf<RangeHighlighter>()
private val strip = PromptAttachmentStrip(project) { removeAttachment(it) }
private var bus: MessageBusConnection? = null
private var lookupBus: MessageBusConnection? = null
private var completionAction: AnAction? = null
private var completionTarget: JComponent? = null
private var mentionCaret = false
private var autoApprove = false
private var attachment = true
private var submitting = false
@@ -163,7 +168,6 @@ class PromptPanel(
ed.settings.isUseSoftWraps = true
ed.settings.isPaintSoftWraps = false
ed.settings.isAdditionalPageAtBottom = false
ed.putUserData(AutoPopupController.ALWAYS_AUTO_POPUP, true)
ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) })
ed.scrollPane.verticalScrollBarPolicy =
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
@@ -173,6 +177,15 @@ class PromptPanel(
installFileDrop(ed.contentComponent, "editor")
installFileDrop(ed.scrollPane, "scroll")
syncHighlights()
ed.caretModel.addCaretListener(object : CaretListener {
override fun caretPositionChanged(e: CaretEvent) {
val provider = completion ?: return
val inside = provider.inside(ed.document.text, ed.caretModel.offset)
if (mentionCaret == inside) return
mentionCaret = inside
syncHighlights()
}
})
ed.contentComponent.addFocusListener(object : FocusAdapter() {
override fun focusGained(e: FocusEvent) {
repaint()
@@ -286,6 +299,7 @@ class PromptPanel(
@RequiresEdt
fun setReady(value: Boolean) {
ready = value
if (value) completion?.prewarm()
if (!value) invalidateEnhancement() else syncEnhance()
}
@@ -367,6 +381,7 @@ class PromptPanel(
editor.text = ""
attachments.clear()
completion?.clearMentions()
completion?.prewarm()
strip.clear()
syncEditorHeight()
syncHighlights()
@@ -379,7 +394,14 @@ class PromptPanel(
highlighters.forEach(ed.markupModel::removeHighlighter)
highlighters.clear()
val length = ed.document.textLength
provider.highlights(ed.document.text).forEach { item ->
val text = ed.document.text
val caret = ed.caretModel.offset
provider.validate(text, caret) {
ApplicationManager.getApplication().invokeLater {
if (!project.isDisposed) refreshHighlights()
}
}
provider.highlights(text, caret).forEach { item ->
val start = item.start.coerceIn(0, length)
val end = item.end.coerceIn(start, length)
if (start == end) return@forEach
@@ -391,11 +413,13 @@ class PromptPanel(
HighlighterTargetArea.EXACT_RANGE,
)
}
mentionCaret = provider.inside(text, caret)
}
private fun key(kind: KiloPromptCompletionProvider.HighlightKind): TextAttributesKey = when (kind) {
KiloPromptCompletionProvider.HighlightKind.MENTION -> MENTION_KEY
KiloPromptCompletionProvider.HighlightKind.COMMAND -> COMMAND_KEY
KiloPromptCompletionProvider.HighlightKind.INVALID -> INVALID_KEY
}
@RequiresEdt
@@ -414,6 +438,7 @@ class PromptPanel(
super.addNotify()
bindRoot()
bindKeymap()
bindLookup()
}
override fun removeNotify() {
@@ -421,6 +446,8 @@ class PromptPanel(
root = null
bus?.disconnect()
bus = null
lookupBus?.disconnect()
lookupBus = null
uninstallCompletionShortcut()
super.removeNotify()
}
@@ -524,10 +551,6 @@ class PromptPanel(
// 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
lookup.presentation = LookupPresentation.Builder(lookup.presentation)
.withPositionStrategy(LookupPositionStrategy.ONLY_ABOVE)
.build()
}
@RequiresEdt
@@ -689,6 +712,20 @@ class PromptPanel(
})
}
@RequiresEdt
private fun bindLookup() {
if (lookupBus != null) return
val connection = project.messageBus.connect()
lookupBus = connection
connection.subscribe(LookupManagerListener.TOPIC, LookupManagerListener { _, next ->
val lookup = next as? LookupImpl ?: return@LookupManagerListener
if (lookup.editor !== editor.getEditor(false)) return@LookupManagerListener
lookup.presentation = LookupPresentation.Builder(lookup.presentation)
.withPositionStrategy(LookupPositionStrategy.ONLY_ABOVE)
.build()
})
}
@RequiresEdt
private fun syncTooltip() {
button.toolTipText = tooltip()
@@ -161,6 +161,7 @@ prompt.action.enhance.loading=Enhancing prompt...
prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.
prompt.action.enhance.failed=Failed to enhance prompt
prompt.action.enhance.failed.description=The configured small model could not enhance this prompt.
prompt.completion.noMatches=No matches
prompt.mention.indexing=Indexing project files. File mentions will be available soon.
prompt.mention.gitChanges=Attach current git changes
prompt.slash.new=Start a new session
@@ -14,8 +14,13 @@ import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.icons.AllIcons
import com.intellij.codeInsight.lookup.Lookup
import com.intellij.codeInsight.lookup.LookupManager
import com.intellij.codeInsight.lookup.LookupPositionStrategy
import com.intellij.codeInsight.lookup.impl.LookupImpl
import com.intellij.notification.Notification
import com.intellij.notification.Notifications
import com.intellij.openapi.actionSystem.ActionPlaces
@@ -25,6 +30,7 @@ import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.application.ApplicationManager
@@ -32,6 +38,10 @@ import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.actions.PasteAction
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.command.undo.UndoManager
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -225,15 +235,98 @@ class PromptPanelTest : BasePlatformTestCase() {
fun `test prompt editor highlights validated commands and mentions`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
rpc.fileResolver = { emptyList() }
realize(panel, 260, 400)
field.text = "/new use @git-changes and @unknown"
UIUtil.dispatchAllInvocationEvents()
field.text = "/new use @git-changes and @unknown "
field.getEditor(false)!!.caretModel.moveToOffset(field.text.length)
panel.refreshHighlights()
waitForSend { spans(field).any { it.first == "@unknown" } }
val spans = spans(field)
assertTrue(spans.contains("/new" to DefaultLanguageHighlighterColors.KEYWORD))
assertTrue(spans.contains("@git-changes" to DefaultLanguageHighlighterColors.METADATA))
assertFalse(spans.any { it.first == "@unknown" })
assertTrue(spans.contains("@unknown" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES))
}
fun `test prompt editor exposes file editor for undo redo`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
val editor = field.getEditor(false)!!
WriteCommandAction.runWriteCommandAction(project) {
editor.document.insertString(0, "hello")
}
val sink = TestSink()
(field as UiDataProvider).uiDataSnapshot(sink)
val file = sink.file as? TextEditor ?: error("missing file editor")
assertNotNull(file)
assertSame(editor.document, file.editor.document)
UndoManager.getInstance(project).undo(file)
assertEquals("", editor.document.text)
UndoManager.getInstance(project).redo(file)
assertEquals("hello", editor.document.text)
}
fun `test prompt editor highlights missing mention as wrong reference`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
rpc.fileResolver = { emptyList() }
realize(panel, 260, 400)
field.text = "@missing "
field.getEditor(false)!!.caretModel.moveToOffset(field.text.length)
panel.refreshHighlights()
waitForSend { spans(field).contains("@missing" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES) }
assertTrue(spans(field).contains("@missing" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES))
}
fun `test accepted file mention highlights immediately`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts")))
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
waitForLookupItems(editor)
acceptLookup(editor)
waitForSend { spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA) }
assertTrue(spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA))
}
fun `test invalid edited file mention highlights after caret leaves token`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts")))
rpc.fileResolver = { path -> if (path == "src/deploy.ts") listOf(file(path)) else emptyList() }
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
waitForLookupItems(editor)
acceptLookup(editor)
waitForSend { spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA) }
val offset = field.text.indexOf(' ')
WriteCommandAction.runWriteCommandAction(project) {
editor.document.insertString(offset, "x")
}
editor.caretModel.moveToOffset(offset + 1)
UIUtil.dispatchAllInvocationEvents()
editor.caretModel.moveToOffset(field.text.length)
waitForSend { spans(field).contains("@src/deploy.tsx" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES) }
assertTrue(spans(field).contains("@src/deploy.tsx" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES))
}
fun `test prompt clear removes prompt highlighters`() {
@@ -296,6 +389,25 @@ class PromptPanelTest : BasePlatformTestCase() {
assertTrue("items=$items", items.contains("new"))
}
fun `test prompt completion lookup is positioned above caret`() {
rpc.searchResult = FileSearchResultDto(
files = listOf(WorkspaceFileDto("src/deploy.ts", "deploy.ts")),
)
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
waitForLookupItems(editor)
val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup")
assertEquals(LookupPositionStrategy.ONLY_ABOVE, lookup.presentation.positionStrategy)
}
fun `test prompt editor shrinks when lines are removed`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val editor = panel.defaultFocusedComponent as EditorTextField
@@ -838,6 +950,7 @@ class PromptPanelTest : BasePlatformTestCase() {
KiloPromptCompletionProvider.SlashAction("new", "New") {},
KiloPromptCompletionProvider.SlashAction("next", "Next") {},
),
scope = scope,
)
private fun invokeCompletionAction(editor: Editor) {
@@ -859,6 +972,17 @@ class PromptPanelTest : BasePlatformTestCase() {
return LookupManager.getActiveLookup(editor)?.items.orEmpty().map { it.lookupString }
}
private fun acceptLookup(editor: Editor) {
val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup")
lookup.finishLookup(Lookup.NORMAL_SELECT_CHAR)
UIUtil.dispatchAllInvocationEvents()
}
private fun file(path: String) = WorkspaceFileDto(
path = path,
name = path.substringAfterLast('/'),
)
private fun event(action: AnAction, editor: Editor): AnActionEvent {
val ctx = DataContext { id ->
when (id) {
@@ -942,9 +1066,11 @@ class PromptPanelTest : BasePlatformTestCase() {
private class TestSink : DataSink {
var send: Any? = null
var file: Any? = null
override fun <T : Any> set(key: com.intellij.openapi.actionSystem.DataKey<T>, data: T?) {
if (key == PromptDataKeys.SEND) send = data
if (key == PlatformCoreDataKeys.FILE_EDITOR) file = data
}
override fun <T : Any> setNull(key: com.intellij.openapi.actionSystem.DataKey<T>) {
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.FileSearchResultDto
@@ -31,7 +32,11 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
provider = KiloPromptCompletionProvider(
workspace = workspaces.workspace("/test"),
service = workspaces,
actions = listOf(KiloPromptCompletionProvider.SlashAction("new", "New") {}),
actions = listOf(
KiloPromptCompletionProvider.SlashAction("new", "New") {},
KiloPromptCompletionProvider.SlashAction("next", "Next") {},
),
scope = scope,
)
}
@@ -49,9 +54,26 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
complete("@sfb<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "src/foo/Bar.kt")
assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches()))
assertEquals(listOf("sfb"), rpc.searchQueries)
}
fun `test mention completion opens in middle of token`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts")))
complete("@dep<caret>loy")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "src/deploy.ts")
assertEquals(listOf("dep"), rpc.searchQueries)
}
fun `test slash completion opens in middle of token`() {
complete("/ne<caret>w")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new")
assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches()))
}
fun `test mention completion reuses identical prefix result`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt")))
@@ -80,6 +102,59 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
assertEquals(listOf("git"), rpc.searchQueries)
}
fun `test mention completion keeps no-match placeholder`() {
rpc.searchResult = FileSearchResultDto()
complete("@zzz<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), noMatches())
assertFalse(myFixture.lookupElementStrings.orEmpty().contains("src/foo/Bar.kt"))
assertEquals(listOf("zzz"), rpc.searchQueries)
}
fun `test accepting mention no-match placeholder preserves prefix`() {
rpc.searchResult = FileSearchResultDto()
complete("@zzz<caret>")
myFixture.type('\n')
assertEquals("@zzz", myFixture.editor.document.text)
assertTrue(provider.mentionPaths().isEmpty())
}
fun `test accepting mention mid token replaces glued suffix`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("backend/deploy-dev.sh")))
complete("@backend/deploy<caret>-dev.sh")
myFixture.type('\n')
assertEquals("@backend/deploy-dev.sh ", myFixture.editor.document.text)
assertTrue(provider.mentionPaths().contains("backend/deploy-dev.sh"))
}
fun `test accepting mention mid token trims before trailing content`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("backend/deploy-dev.sh")))
complete("@backend/deploy<caret>-dev.sh tail")
myFixture.type('\n')
assertEquals("@backend/deploy-dev.sh tail", myFixture.editor.document.text)
assertTrue(provider.mentionPaths().contains("backend/deploy-dev.sh"))
}
fun `test slash completion keeps no-match placeholder`() {
complete("/zzz<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), noMatches())
}
fun `test slash completion hides placeholder for real matches`() {
complete("/ne<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new")
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")),
@@ -94,6 +169,20 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
assertEquals(listOf(""), rpc.searchQueries)
}
fun `test prewarm serves blank mention completion from cache`() {
rpc.searchResult = FileSearchResultDto(
files = listOf(file("src", directory = true), file("README.md")),
git = true,
)
provider.prewarm()
waitFor { rpc.searchQueries.contains("") }
complete("@<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "git-changes", "src", "README.md")
assertEquals(listOf(""), rpc.searchQueries)
}
fun `test mention completion renders file type icons`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("image.png"), file("src", directory = true)))
@@ -156,10 +245,40 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
)
}
fun `test highlights ignore untracked mentions`() {
fun `test highlights unknown mentions are pending before validation`() {
assertTrue(provider.highlights("see @unknownPath").isEmpty())
}
fun `test highlights unresolved mention after validation`() {
var done = false
rpc.fileResolver = { emptyList() }
provider.validate("see @unknownPath", -1) { done = true }
waitFor { done }
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.INVALID)),
provider.highlights("see @unknownPath", -1),
)
}
fun `test highlights existing hand typed mention after validation`() {
var done = false
rpc.fileResolver = { path -> if (path == "src/x.kt") listOf(file(path)) else emptyList() }
provider.validate("see @src/x.kt", -1) { done = true }
waitFor { done }
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(4, 13, KiloPromptCompletionProvider.HighlightKind.MENTION)),
provider.highlights("see @src/x.kt", -1),
)
}
fun `test mention under caret is not flagged`() {
assertTrue(provider.highlights("@nope", caret = 5).isEmpty())
}
private fun complete(text: String) {
val file = myFixture.configureByText("prompt.txt", text)
TextCompletionUtil.installProvider(file, provider, true)
@@ -190,4 +309,6 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
name = path.substringAfterLast('/'),
directory = directory,
)
private fun noMatches() = KiloBundle.message("prompt.completion.noMatches")
}