From 09a08fbac6073f67abd1360be329f29cdf1218b5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 20 Aug 2026 15:15:59 -0400 Subject: [PATCH] fix(jetbrains): stabilize slash completion --- .../jetbrains-slash-completion-fast-typing.md | 5 ++ .../client/session/ui/ReasoningPicker.kt | 1 + .../client/session/ui/mode/ModePicker.kt | 1 + .../client/session/ui/model/ModelPicker.kt | 2 +- .../ui/prompt/KiloPromptCompletionProvider.kt | 18 +++++++ .../client/session/ui/prompt/PromptPanel.kt | 44 +++++++++++++-- .../ai/kilocode/client/ui/PickerButton.kt | 16 ++++++ .../client/session/ui/PromptPanelTest.kt | 53 +++++++++++++++++++ .../ai/kilocode/client/ui/PickerButtonTest.kt | 17 ++++++ 9 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 .changeset/jetbrains-slash-completion-fast-typing.md create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/PickerButtonTest.kt diff --git a/.changeset/jetbrains-slash-completion-fast-typing.md b/.changeset/jetbrains-slash-completion-fast-typing.md new file mode 100644 index 00000000000..2ea93a591b5 --- /dev/null +++ b/.changeset/jetbrains-slash-completion-fast-typing.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep the slash-command completion popup open while typing quickly and reopen it if it closes mid-token, so fast typing filters commands instead of dismissing the list. Refresh the popup when server commands finish loading, and return focus to the prompt after picking a model, agent, or reasoning option from a slash command. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt index cd1ac3e39b9..1c19a35c45c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt @@ -101,6 +101,7 @@ class ReasoningPicker : PickerButton() { } val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step) + restoreFocusOnPick(popup) popup.show(PopupShowOptions.aboveComponent(this)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt index c3381ec4d41..dd529883539 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt @@ -92,6 +92,7 @@ class ModePicker : PickerButton() { } .createPopup() + restoreFocusOnPick(popup) popup.show(PopupShowOptions.aboveComponent(this)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index af43fa980d4..6acef72f2c5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -205,7 +205,7 @@ class ModelPicker : PickerButton() { maxVisibleRows = MODEL_PICKER_MAX_VISIBLE_ROWS, emptyListHeight = MODEL_PICKER_EMPTY_LIST_HEIGHT, ) - popup.show() + restoreFocusOnPick(popup.show()) } private fun favoriteKeys(): Set = favorites().mapTo(mutableSetOf()) { "${it.providerID}/${it.modelID}" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index d8fff547bbd..2da4ce6f1e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -23,6 +23,12 @@ import com.intellij.openapi.progress.runBlockingCancellable import com.intellij.util.textCompletion.TextCompletionProvider import java.util.Collections import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch class KiloPromptCompletionProvider( @@ -82,6 +88,18 @@ class KiloPromptCompletionProvider( fun inside(text: String, caret: Int): Boolean = mentionSpans(text).any { span -> caret in span.start..span.end } + fun completing(text: String, caret: Int): Boolean = token(text, caret) != null + + fun completingSlash(text: String, caret: Int): Boolean = token(text, caret)?.kind == Kind.SLASH + + fun watchCommands(onChanged: () -> Unit): Job = + workspace.state + .map { it.commands } + .distinctUntilChanged() + .drop(1) + .onEach { onChanged() } + .launchIn(scope) + private fun clientTokens(): Set = actions.flatMapTo(mutableSetOf()) { action -> listOf(action.name) + action.hints } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index e17cb8643fc..055f85af82e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -24,6 +24,7 @@ import com.intellij.icons.AllIcons import com.intellij.codeInsight.completion.CodeCompletionHandlerBase import com.intellij.codeInsight.completion.CompletionType import com.intellij.codeInsight.lookup.LookupEx +import com.intellij.codeInsight.lookup.LookupManager import com.intellij.codeInsight.lookup.LookupManagerListener import com.intellij.codeInsight.lookup.LookupPositionStrategy import com.intellij.codeInsight.lookup.LookupPresentation @@ -71,6 +72,7 @@ import com.intellij.util.messages.MessageBusConnection import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -160,6 +162,7 @@ class PromptPanel( private val strip = PromptAttachmentStrip(project) { removeAttachment(it) } private var bus: MessageBusConnection? = null private var lookupBus: MessageBusConnection? = null + private var commandJob: Job? = null private var completionAction: AnAction? = null private var completionTarget: JComponent? = null private var mentionCaret = false @@ -277,6 +280,9 @@ class PromptPanel( applyStyle(style) syncBorder() selection?.register(editor) + mode.onPickClose = ::focusLater + model.onPickClose = ::focusLater + reasoning.onPickClose = ::focusLater editor.text = "" editor.addDocumentListener(object : DocumentListener { override fun documentChanged(e: DocumentEvent) { @@ -603,11 +609,19 @@ class PromptPanel( editor.requestFocusInWindow() } + private fun focusLater() { + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed || !isShowing) return@invokeLater + focus() + } + } + override fun addNotify() { super.addNotify() bindRoot() bindKeymap() bindLookup() + bindCommandRefresh() } override fun removeNotify() { @@ -617,6 +631,8 @@ class PromptPanel( bus = null lookupBus?.disconnect() lookupBus = null + commandJob?.cancel() + commandJob = null uninstallCompletionShortcut() super.removeNotify() } @@ -714,18 +730,40 @@ class PromptPanel( private fun triggerCompletion(e: DocumentEvent) { if (project.isDisposed) return + val provider = completion ?: return val value = e.newFragment.toString() if (value.length != 1) return val text = editor.text - val offset = e.offset + value.length - val popup = value == "@" || (value == "/" && text.take(offset).trim() == "/") - if (!popup) return + val offset = (e.offset + value.length).coerceIn(0, text.length) + val initial = value == "@" || (value == "/" && text.take(offset).trim() == "/") + val ed = editor.getEditor(false) + val open = ed?.let { LookupManager.getActiveLookup(it) } != null + // Reopen when a keystroke lands inside a slash/mention token but the lookup has closed. + // Fast typing can drop the platform's completion restart: the @-mention path stays open + // because its backend search keeps the completion calculating, while the instant slash + // path settles and can disappear. Re-triggering self-heals it via the same manual path. + val retry = !initial && !open && provider.completing(text, offset) + if (!initial && !retry) return ApplicationManager.getApplication().invokeLater { if (project.isDisposed) return@invokeLater editor.getEditor(false)?.let(::showCompletion) } } + @RequiresEdt + private fun bindCommandRefresh() { + if (completion == null || commandJob != null) return + commandJob = completion.watchCommands { + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + val ed = editor.getEditor(false) ?: return@invokeLater + if (LookupManager.getActiveLookup(ed) == null) return@invokeLater + if (!completion.completingSlash(ed.document.text, ed.caretModel.offset)) return@invokeLater + showCompletion(ed) + } + } + } + @RequiresEdt @Suppress("UnstableApiUsage") private fun showCompletion(ed: com.intellij.openapi.editor.Editor) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt index f2ccbdd8564..b78df8dc1eb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt @@ -1,5 +1,8 @@ package ai.kilocode.client.ui +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupListener +import com.intellij.openapi.ui.popup.LightweightWindowEvent import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.Color @@ -12,6 +15,8 @@ import java.awt.event.MouseEvent open class PickerButton : JBLabel() { private var over = false + var onPickClose: () -> Unit = {} + /** * Idle (unhovered) fill. Defaults to the standard picker surface; set to `null` to paint * nothing so the picker blends into its container (e.g. the prompt background). The hover @@ -63,5 +68,16 @@ open class PickerButton : JBLabel() { repaint() } + protected fun restoreFocusOnPick(popup: JBPopup) { + popup.addListener(object : JBPopupListener { + override fun onClosed(event: LightweightWindowEvent) = pickClosed(event.isOk) + }) + } + + /** Popup close handler: [ok] is true only when a value was chosen (not on cancel/escape). */ + internal fun pickClosed(ok: Boolean) { + if (ok) onPickClose() + } + private fun pickerBorder() = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.lg()) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index bd2e332eb22..d929f46ac05 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -18,7 +18,10 @@ import ai.kilocode.client.session.ui.prompt.SlashAction import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.test.CopyProviderSink import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.FileSearchResultDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.ide.actions.UndoRedoAction @@ -693,6 +696,46 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue("items=$items", items.contains("new")) } + fun `test slash lookup reopens while typing after it closes`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + // The whole-token set does not open a lookup (matches a paste / programmatic set, not typing). + field.text = "/n" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + assertNull("no lookup expected before typing", LookupManager.getActiveLookup(editor)) + + // A single keystroke inside the slash token reopens the completion, simulating the popup + // having closed during fast typing. + WriteCommandAction.runWriteCommandAction(project) { + editor.document.insertString(editor.caretModel.offset, "e") + } + editor.caretModel.moveToOffset(editor.document.textLength) + + val items = waitForLookupItems(editor) + assertTrue("items=$items", items.contains("new")) + } + + fun `test slash lookup refreshes when server commands load`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + field.text = "/deploy" + val editor = field.getEditor(false)!! + editor.caretModel.moveToOffset(field.text.length) + + invokeCompletionAction(editor) + val before = waitForLookupItems(editor) + assertFalse("before=$before", before.contains("deploy")) + + rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("deploy"))) + + assertTrue("expected deploy after load", waitForLookupItem(editor, "deploy")) + } + fun `test prompt completion lookup is positioned above caret`() { rpc.searchResult = FileSearchResultDto( files = listOf(WorkspaceFileDto("src/deploy.ts", "deploy.ts")), @@ -1523,6 +1566,16 @@ class PromptPanelTest : BasePlatformTestCase() { return LookupManager.getActiveLookup(editor)?.items.orEmpty().map { it.lookupString } } + private fun waitForLookupItem(editor: Editor, value: String): Boolean { + repeat(50) { + UIUtil.dispatchAllInvocationEvents() + val items = LookupManager.getActiveLookup(editor)?.items.orEmpty().map { it.lookupString } + if (items.contains(value)) return true + Thread.sleep(20) + } + return false + } + private fun acceptLookup(editor: Editor) { val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup") lookup.finishLookup(Lookup.NORMAL_SELECT_CHAR) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/PickerButtonTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/PickerButtonTest.kt new file mode 100644 index 00000000000..b352af8097b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/PickerButtonTest.kt @@ -0,0 +1,17 @@ +package ai.kilocode.client.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class PickerButtonTest : BasePlatformTestCase() { + + fun `test pick close restores focus only when a value is chosen`() { + var calls = 0 + val button = PickerButton().apply { onPickClose = { calls++ } } + + button.pickClosed(false) + assertEquals(0, calls) + + button.pickClosed(true) + assertEquals(1, calls) + } +}