Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-slash-completion-fast-typing.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class ReasoningPicker : PickerButton() {
}

val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step)
restoreFocusOnPick(popup)
popup.show(PopupShowOptions.aboveComponent(this))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class ModePicker : PickerButton() {
}
.createPopup()

restoreFocusOnPick(popup)
popup.show(PopupShowOptions.aboveComponent(this))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand All @@ -617,6 +631,8 @@ class PromptPanel(
bus = null
lookupBus?.disconnect()
lookupBus = null
commandJob?.cancel()
commandJob = null
uninstallCompletionShortcut()
super.removeNotify()
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")),
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading