mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
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:
@@ -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.
|
||||
+1
@@ -101,6 +101,7 @@ class ReasoningPicker : PickerButton() {
|
||||
}
|
||||
|
||||
val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step)
|
||||
restoreFocusOnPick(popup)
|
||||
popup.show(PopupShowOptions.aboveComponent(this))
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ class ModePicker : PickerButton() {
|
||||
}
|
||||
.createPopup()
|
||||
|
||||
restoreFocusOnPick(popup)
|
||||
popup.show(PopupShowOptions.aboveComponent(this))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}" }
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
|
||||
+41
-3
@@ -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) {
|
||||
|
||||
+16
@@ -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())
|
||||
}
|
||||
|
||||
+53
@@ -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)
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user