fix(jetbrains): handle prompt bulk updates and context apply

Skip prompt editor height and highlight recalculation while the editor document is in bulk update, then schedule one refresh after bulk mode exits so undo/redo cannot trigger UnexpectedBulkUpdateStateException.

Also include the local Auto-Include Editor Context setting in the Context page draft state so toggling it marks the configurable modified and Apply persists it without sending a CLI config patch.
This commit is contained in:
kirillk
2026-08-10 09:48:35 -04:00
parent 0293919506
commit aaf2820162
5 changed files with 78 additions and 11 deletions
@@ -42,6 +42,7 @@ 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.Document
import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.TextAttributesKey
@@ -256,6 +257,7 @@ class PromptPanel(
private var ready = false
private var enhancing = false
private var request = 0L
private var deferred = false
override val isSendEnabled: Boolean
get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
@@ -270,12 +272,22 @@ class PromptPanel(
editor.addDocumentListener(object : DocumentListener {
override fun documentChanged(e: DocumentEvent) {
invalidateEnhancement()
if (e.document.isInBulkUpdate) {
deferEditorSync()
syncButton()
onChange()
return
}
syncEditorHeight()
triggerCompletion(e)
syncHighlights()
syncButton()
onChange()
}
override fun bulkUpdateFinished(document: Document) {
deferEditorSync()
}
})
shell.add(strip, BorderLayout.NORTH)
shell.add(editor, BorderLayout.CENTER)
@@ -937,6 +949,10 @@ class PromptPanel(
@RequiresEdt
private fun syncEditorHeight() {
if (editor.document.isInBulkUpdate) {
deferEditorSync()
return
}
val before = editor.preferredSize.height
val lower = editor.minimumSize.height
editor.setPreferredSize(null)
@@ -968,6 +984,19 @@ class PromptPanel(
repaint()
}
@RequiresEdt
private fun deferEditorSync() {
if (deferred) return
deferred = true
ApplicationManager.getApplication().invokeLater {
deferred = false
if (project.isDisposed || editor.document.isInBulkUpdate) return@invokeLater
syncEditorHeight()
syncHighlights()
syncButton()
}
}
@RequiresEdt
private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) {
// AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.context
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.rpc.dto.CompactionPatchDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto
@@ -9,6 +10,7 @@ internal data class ContextDraft(
val auto: Boolean = false,
val threshold: String = "",
val prune: Boolean = false,
val editor: Boolean = KiloPluginSettings.getAutoEditorContext(),
val ignore: List<String> = emptyList(),
)
@@ -21,6 +23,7 @@ internal fun contextDraft(config: ConfigDto?): ContextDraft = ContextDraft(
auto = config?.compaction?.auto ?: false,
threshold = config?.compaction?.threshold_percent?.let(::formatThreshold).orEmpty(),
prune = config?.compaction?.prune ?: false,
editor = KiloPluginSettings.getAutoEditorContext(),
ignore = config?.watcher?.ignore ?: emptyList(),
)
@@ -38,8 +41,11 @@ internal fun savedMatches(base: ContextDraft, draft: ContextDraft): Boolean =
base.auto == draft.auto &&
normalizeThreshold(base.threshold) == normalizeThreshold(draft.threshold) &&
base.prune == draft.prune &&
base.editor == draft.editor &&
base.ignore == draft.ignore
internal fun localChanged(base: ContextDraft, draft: ContextDraft): Boolean = base.editor != draft.editor
internal fun thresholdStatus(value: String): ThresholdStatus {
val text = value.trim()
if (text.isBlank()) return ThresholdStatus.VALID
@@ -63,10 +63,23 @@ internal class ContextSettingsUi(
startSettings(ContextSettingsContent { updateDraft(it) })
}
override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to)?.takeIf(::changed)
override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? {
val patch = patch(from, to) ?: return null
if (changed(patch)) return patch
return ConfigPatchDto().takeIf { localChanged(from, to) }
}
override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) {
app.updateConfigAsync(change, done)
val value = draft.editor
if (!changed(change)) {
KiloPluginSettings.setAutoEditorContext(value)
done(appState)
return
}
app.updateConfigAsync(change) { result ->
if (result != null) KiloPluginSettings.setAutoEditorContext(value)
done(result)
}
}
override fun base(result: KiloAppStateDto): ContextDraft = contextDraft(result.config)
@@ -136,11 +149,9 @@ internal class ContextSettingsContent(
) : BaseContentPanel() {
private val auto = SettingsToggle { value -> update { copy(auto = value) } }
// Editor-context auto-include is a local per-IDE preference in PropertiesComponent (like
// autoApprove), applied immediately on toggle rather than through the CLI-backed draft/apply/
// reset flow used by the other rows. It stays interactive even when the backend isn't READY.
private val editor = SettingsToggle(KiloPluginSettings.getAutoEditorContext()) { value ->
KiloPluginSettings.setAutoEditorContext(value)
}
// autoApprove). It participates in this page's draft/apply/reset state so the Configurable
// Apply button reflects unsaved local changes, but it is never sent as CLI config.
private val editor = SettingsToggle { value -> update { copy(editor = value) } }
private val prune = SettingsToggle { value -> update { copy(prune = value) } }
private val threshold = ThresholdField(
KiloBundle.message("settings.context.compaction.threshold.placeholder"),
@@ -186,9 +197,9 @@ internal class ContextSettingsContent(
@RequiresEdt
fun sync(draft: ContextDraft, enabled: Boolean) {
auto.isSelected = draft.auto
// Local preference: reflects PropertiesComponent and stays enabled regardless of the
// CLI-backed [enabled] gating that applies to the draft-driven rows below.
editor.isSelected = KiloPluginSettings.getAutoEditorContext()
// Local preference: draft-driven, but still enabled regardless of the CLI-backed [enabled]
// gating that applies to the remote config rows below.
editor.isSelected = draft.editor
editor.isEnabled = true
prune.isSelected = draft.prune
threshold.sync(draft.threshold)
@@ -70,6 +70,7 @@ import com.intellij.ui.components.JBLabel
import com.intellij.util.Producer
import com.intellij.util.ui.EmptyIcon
import com.intellij.ui.scale.JBUIScale
import com.intellij.util.DocumentUtil
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
@@ -501,6 +502,22 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals("hello", editor.document.text)
}
fun `test prompt editor height sync skips bulk document updates`() {
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) {
DocumentUtil.executeInBulk(editor.document, true) {
editor.document.insertString(0, "hello")
}
}
UIUtil.dispatchAllInvocationEvents()
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
@@ -274,7 +274,7 @@ class ContextSettingsUiTest : BasePlatformTestCase() {
flushUntil { rpc.configPatches.isNotEmpty() }
}
fun `test editor context toggle persists immediately without a config patch`() {
fun `test editor context toggle marks modified and applies without a config patch`() {
val panel = requireUi()
assertTrue(KiloPluginSettings.getAutoEditorContext())
@@ -282,10 +282,14 @@ class ContextSettingsUiTest : BasePlatformTestCase() {
val editor = editorToggle(panel)
assertTrue(editor.isEnabled)
editor.doClick()
assertTrue(panel.modified())
}
assertTrue(KiloPluginSettings.getAutoEditorContext())
edt { panel.applyDraft() }
assertFalse(KiloPluginSettings.getAutoEditorContext())
edt { UIUtil.dispatchAllInvocationEvents() }
assertFalse(edt { panel.modified() })
assertTrue(rpc.configPatches.isEmpty())
}