mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix(jetbrains): stabilize prompt mention completion
This commit is contained in:
@@ -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).
|
||||
+106
-38
@@ -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 }
|
||||
}
|
||||
|
||||
+45
-8
@@ -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
|
||||
|
||||
+129
-3
@@ -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>) {
|
||||
|
||||
+123
-2
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user