Merge pull request #13287 from Kilo-Org/fix/jetbrains-slash-completion-fast-typing

fix(jetbrains): keep slash completion open while typing
This commit is contained in:
Kirill Kalishev
2026-08-21 03:42:51 -04:00
committed by GitHub
9 changed files with 153 additions and 4 deletions
@@ -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.
@@ -101,6 +101,7 @@ class ReasoningPicker : PickerButton() {
}
val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step)
restoreFocusOnPick(popup)
popup.show(PopupShowOptions.aboveComponent(this))
}
@@ -92,6 +92,7 @@ class ModePicker : PickerButton() {
}
.createPopup()
restoreFocusOnPick(popup)
popup.show(PopupShowOptions.aboveComponent(this))
}
}
@@ -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<String> = favorites().mapTo(mutableSetOf()) { "${it.providerID}/${it.modelID}" }
@@ -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<String> = actions.flatMapTo(mutableSetOf()) { action ->
listOf(action.name) + action.hints
}
@@ -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) {
@@ -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())
}
@@ -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)
@@ -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)
}
}