fix(kilo-jetbrains): show settings progress as overlay

This commit is contained in:
kirillk
2026-06-02 13:00:40 -04:00
parent cf0fd63f0f
commit ede4369ba3
13 changed files with 840 additions and 287 deletions
@@ -1,156 +1,16 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Container
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JComponent
import javax.swing.JLayeredPane
import javax.swing.JPanel
import ai.kilocode.client.ui.LayeredOverlayPanel
class SessionRootPanel : JLayeredPane() {
class SessionRootPanel(
private val sessionOverlay: Overlay = Overlay(),
private val sessionBlocker: Blocker = Blocker(),
) : LayeredOverlayPanel(overlay = sessionOverlay, blocker = sessionBlocker) {
override val overlay: Overlay get() = sessionOverlay
val content: JPanel = BorderLayoutPanel()
override val blocker: Blocker get() = sessionBlocker
val overlay = Overlay()
class Overlay : LayeredOverlayPanel.Overlay()
val blocker = Blocker()
init {
layout = null
add(content)
setLayer(content, DEFAULT_LAYER)
add(overlay)
setLayer(overlay, PALETTE_LAYER)
add(blocker)
setLayer(blocker, MODAL_LAYER)
blocker.isVisible = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
overlay.addOverlay(child, bounds)
}
@RequiresEdt
fun setModalContent(child: JComponent?) {
blocker.removeAll()
if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
blocker.isVisible = child != null
if (child != null) blocker.requestFocusInWindow()
invalidate()
blocker.invalidate()
child?.invalidate()
if (width > 0 && height > 0) {
doLayout()
child?.let(::layoutTree)
}
blocker.revalidate()
blocker.repaint()
revalidate()
repaint()
}
@RequiresEdt
fun setBlocked(value: Boolean) {
blocker.isVisible = value
if (value) blocker.requestFocusInWindow()
invalidate()
blocker.invalidate()
if (width > 0 && height > 0) doLayout()
revalidate()
repaint()
}
override fun doLayout() {
components
.sortedBy { getLayer(it) }
.forEach { child ->
child.setBounds(0, 0, width, height)
child.doLayout()
}
}
override fun getPreferredSize(): Dimension {
// Only content and overlay contribute to preferred size; blocker is invisible by default.
val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0
val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0
return JBDimension(w, h)
}
class Overlay : BorderLayoutPanel() {
private val items = linkedMapOf<JComponent, (JPanel, JComponent) -> Rectangle>()
init {
layout = null
// The overlay must let mouse events fall through outside visible children.
isOpaque = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
items[child] = bounds
add(child)
}
override fun contains(x: Int, y: Int): Boolean {
for (child in components) {
if (child.isVisible && child.bounds.contains(x, y)) return true
}
return false
}
override fun doLayout() {
items.forEach { (child, bounds) ->
child.bounds = bounds(this, child)
child.doLayout()
}
}
override fun getPreferredSize(): Dimension {
val pref = super.getPreferredSize()
val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0)
val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0)
return JBDimension(w, h)
}
}
/**
* Full-area blocking overlay rendered above the scroll overlay at MODAL_LAYER.
* When visible: consumes all mouse events across the full panel area.
* When hidden: passes all mouse events through (isVisible=false).
*/
class Blocker : JPanel() {
init {
layout = BorderLayout()
isFocusable = true
}
override fun updateUI() {
super.updateUI()
background = UiStyle.Colors.bg()
isOpaque = true
}
override fun contains(x: Int, y: Int): Boolean {
if (!isVisible) return false
return super.contains(x, y)
}
override fun doLayout() {
super.doLayout()
components.forEach { layoutTree(it) }
}
}
}
private fun layoutTree(comp: java.awt.Component) {
comp.doLayout()
if (comp is Container) comp.components.forEach { layoutTree(it) }
class Blocker : LayeredOverlayPanel.Blocker()
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.settings.models
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.options.SearchableConfigurable
import kotlinx.coroutines.CoroutineScope
@@ -11,7 +12,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import javax.swing.JComponent
class ModelsConfigurable : SearchableConfigurable {
class ModelsConfigurable : SearchableConfigurable, Configurable.NoScroll {
private var ui: ModelsSettingsUi? = null
private var scope: CoroutineScope? = null
@@ -8,11 +8,12 @@ import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.model.ModelText
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.settings.profile.edt
import ai.kilocode.client.settings.ui.SettingsBannerKind
import ai.kilocode.client.settings.ui.SettingsContentPanel
import ai.kilocode.client.settings.ui.SettingsPanel
import ai.kilocode.client.settings.ui.SettingsRow
import ai.kilocode.client.settings.ui.SettingsRows
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.AgentDto
@@ -29,48 +30,29 @@ import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.options.ex.Settings
import com.intellij.openapi.project.ProjectManager
import com.intellij.ui.EditorNotificationPanel
import com.intellij.ui.InlineBanner
import com.intellij.ui.TitledSeparator
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.util.function.Predicate
import javax.swing.JComponent
import javax.swing.JPanel
internal class ModelsSettingsUi(
private val cs: CoroutineScope,
private val app: KiloAppService = service(),
private val workspaces: KiloWorkspaceService = service(),
private val directory: String? = null,
) : JPanel(BorderLayout()) {
) : SettingsPanel() {
private val status = JBLabel(KiloBundle.message("settings.models.loading"))
private val banner = InlineBanner(
KiloBundle.message("settings.models.login.message"),
EditorNotificationPanel.Status.Warning,
).showCloseButton(false)
private val content = Stack.vertical()
private val rows = SettingsRows()
private val modes = SettingsRows()
private val defaults = ModelSettingPicker()
private val small = ModelSettingPicker()
private val subagent = ModelSettingPicker()
private val variant = ReasoningPicker()
private val variantRow = SettingsRow(
KiloBundle.message("settings.models.subagentVariant.title"),
KiloBundle.message("settings.models.subagentVariant.description"),
variant.align(HAlign.RIGHT, VAlign.CENTER),
)
private val pickers = mutableMapOf<String, ModelSettingPicker>()
private val form = ModelsSettingsContent(app, ::update, ::selectSubagent)
private val defaults = form.defaults
private val small = form.small
private val subagent = form.subagent
private val variant = form.variant
private val variantRow = form.variantRow
private val pickers = form.pickers
private val jobs = mutableListOf<Job>()
private var baseline = ModelsDraft()
@@ -87,50 +69,7 @@ internal class ModelsSettingsUi(
private var pending: ModelsDraft? = null
init {
status.foreground = UIUtil.getContextHelpForeground()
defaults.picker.onSelect = { update { copy(model = it.key) } }
defaults.picker.onClear = { update { copy(model = null) } }
small.picker.onSelect = { update { copy(small = it.key) } }
small.picker.onClear = { update { copy(small = null) } }
small.picker.includeSmall = true
subagent.picker.onSelect = { item -> selectSubagent(item) }
subagent.picker.onClear = { update { copy(subagent = null, variant = null) } }
variant.onSelect = { item -> update { copy(variant = item.id) } }
listOf(defaults, small, subagent).forEach { picker ->
picker.picker.favorites = { app.favorites.value }
picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) }
}
banner.addAction(KiloBundle.message("settings.models.login.action"), Runnable { openProfile(banner) })
content.next(banner)
content.gap(UiStyle.Gap.md())
content.next(status)
content.gap(UiStyle.Gap.lg())
content.next(TitledSeparator(KiloBundle.message("settings.models.displayName")))
rows.row(SettingsRow(
KiloBundle.message("settings.models.defaultModel.title"),
KiloBundle.message("settings.models.defaultModel.description"),
defaults,
))
rows.row(SettingsRow(
KiloBundle.message("settings.models.smallModel.title"),
KiloBundle.message("settings.models.smallModel.description"),
small,
))
rows.row(SettingsRow(
KiloBundle.message("settings.models.subagentModel.title"),
KiloBundle.message("settings.models.subagentModel.description"),
subagent,
))
rows.row(variantRow)
content.next(rows)
content.next(TitledSeparator(KiloBundle.message("settings.models.modeModels.title")))
content.next(JBLabel(KiloBundle.message("settings.models.modeModels.description")).apply {
foreground = UIUtil.getContextHelpForeground()
font = UiStyle.Fonts.small()
})
content.next(modes)
add(JBScrollPane(content).apply { border = null }, BorderLayout.CENTER)
setContent(form)
sync()
start()
}
@@ -145,7 +84,7 @@ internal class ModelsSettingsUi(
fun resetDraft() {
checkEdt()
draft = baseline
if (!saving) status.text = ""
if (!saving) clearProgress()
sync()
}
@@ -158,8 +97,7 @@ internal class ModelsSettingsUi(
if (patch.values.isEmpty() && patch.agents.isEmpty()) return
pending = next
saving = true
status.text = KiloBundle.message("settings.models.save.pending")
status.foreground = UIUtil.getContextHelpForeground()
showProgress(KiloBundle.message("settings.models.save.pending"))
sync()
cs.launch {
val state = app.updateConfig(patch)
@@ -171,7 +109,7 @@ internal class ModelsSettingsUi(
draft = next
pending = null
saving = false
status.text = ""
clearProgress()
sync()
return@withContext
}
@@ -179,8 +117,7 @@ internal class ModelsSettingsUi(
draft = next
pending = null
saving = false
status.text = KiloBundle.message("settings.models.save.failed")
status.foreground = UiStyle.Colors.errorLabelForeground()
clearProgress()
sync()
}
}
@@ -285,40 +222,28 @@ internal class ModelsSettingsUi(
ready = appState.status == KiloAppStatusDto.READY,
authenticated = appState.profile != null,
)
val structural = banner.isVisible != bannerVisible
banner.isVisible = bannerVisible
if (!saving) {
if (bannerVisible) {
top.showNotLoggedIn { openProfile(it) }
} else {
when (state) {
ModelsStatus.UNAVAILABLE -> {
status.text = KiloBundle.message("settings.models.unavailable")
status.foreground = UIUtil.getContextHelpForeground()
}
ModelsStatus.LOADING -> {
status.text = KiloBundle.message("settings.models.loading")
status.foreground = UIUtil.getContextHelpForeground()
}
ModelsStatus.LOAD_FAILED -> {
status.text = KiloBundle.message("settings.models.load.failed")
status.foreground = UiStyle.Colors.errorLabelForeground()
}
ModelsStatus.NO_PROVIDERS -> {
status.text = KiloBundle.message("settings.models.noProviders")
status.foreground = UIUtil.getContextHelpForeground()
}
ModelsStatus.MODES_FAILED -> {
status.text = KiloBundle.message("settings.models.modes.failed")
status.foreground = UiStyle.Colors.warningLabelForeground()
}
ModelsStatus.READY,
ModelsStatus.SAVING -> {
status.text = ""
status.foreground = UIUtil.getContextHelpForeground()
}
ModelsStatus.LOAD_FAILED -> top.showBanner(
KiloBundle.message("settings.models.load.failed"),
emptyList(),
SettingsBannerKind.ERROR,
)
ModelsStatus.NO_PROVIDERS -> top.showBanner(KiloBundle.message("settings.models.noProviders"), emptyList())
ModelsStatus.MODES_FAILED -> top.showBanner(KiloBundle.message("settings.models.modes.failed"), emptyList())
else -> top.hideBanner()
}
}
val statusVisible = status.text.isNotBlank()
var layout = structural || status.isVisible != statusVisible
status.isVisible = statusVisible
if (saving || state == ModelsStatus.SAVING) {
showProgress(KiloBundle.message("settings.models.save.pending"))
} else if (state == ModelsStatus.UNAVAILABLE || state == ModelsStatus.LOADING) {
showProgress(KiloBundle.message("settings.models.loading"))
} else {
clearProgress()
}
var layout = false
defaults.setItems(allItems, draft.model)
small.setItems(smallItems, draft.small)
subagent.setItems(allItems, draft.subagent)
@@ -364,7 +289,7 @@ internal class ModelsSettingsUi(
var layout = false
val names = agents.map { it.name }
if (names != pickers.keys.toList()) {
modes.removeAll()
form.modes.removeAll()
pickers.clear()
agents.forEach { agent ->
val picker = ModelSettingPicker()
@@ -373,7 +298,7 @@ internal class ModelsSettingsUi(
picker.picker.onSelect = { item -> update { copy(agents = this.agents + (agent.name to item.key)) } }
picker.picker.onClear = { update { copy(agents = this.agents + (agent.name to null)) } }
pickers[agent.name] = picker
modes.row(SettingsRow(
form.modes.row(agent.name, SettingsRow(
agent.displayName ?: title(agent.name),
agent.description,
picker,
@@ -381,7 +306,10 @@ internal class ModelsSettingsUi(
}
layout = true
}
for ((name, picker) in pickers) {
agents.forEach { agent ->
val name = agent.name
val picker = pickers[name] ?: return@forEach
form.modes.update(name, agent.displayName ?: title(name), agent.description, picker)
val value = draft.agents[name]
picker.setItems(allItems, value)
picker.isEnabled = ready
@@ -441,6 +369,61 @@ internal class ModelsSettingsUi(
private const val KILO_PROVIDER = "kilo"
private class ModelsSettingsContent(
app: KiloAppService,
update: (ModelsDraft.() -> ModelsDraft) -> Unit,
select: (ModelPicker.Item) -> Unit,
) : SettingsContentPanel() {
val defaults = ModelSettingPicker()
val small = ModelSettingPicker()
val subagent = ModelSettingPicker()
val variant = ReasoningPicker()
val variantRow = SettingsRow(
KiloBundle.message("settings.models.subagentVariant.title"),
KiloBundle.message("settings.models.subagentVariant.description"),
variant.align(HAlign.RIGHT, VAlign.CENTER),
)
val modes: SettingsRows
val pickers = linkedMapOf<String, ModelSettingPicker>()
init {
defaults.picker.onSelect = { update { copy(model = it.key) } }
defaults.picker.onClear = { update { copy(model = null) } }
small.picker.onSelect = { update { copy(small = it.key) } }
small.picker.onClear = { update { copy(small = null) } }
small.picker.includeSmall = true
subagent.picker.onSelect = { item -> select(item) }
subagent.picker.onClear = { update { copy(subagent = null, variant = null) } }
variant.onSelect = { item -> update { copy(variant = item.id) } }
listOf(defaults, small, subagent).forEach { picker ->
picker.picker.favorites = { app.favorites.value }
picker.picker.onFavoriteToggle = { app.toggleModelFavorite(it.provider, it.id) }
}
val rows = section(KiloBundle.message("settings.models.displayName"))
rows.row(SettingsRow(
KiloBundle.message("settings.models.defaultModel.title"),
KiloBundle.message("settings.models.defaultModel.description"),
defaults,
))
rows.row(SettingsRow(
KiloBundle.message("settings.models.smallModel.title"),
KiloBundle.message("settings.models.smallModel.description"),
small,
))
rows.row(SettingsRow(
KiloBundle.message("settings.models.subagentModel.title"),
KiloBundle.message("settings.models.subagentModel.description"),
subagent,
))
rows.row(variantRow)
modes = section(
KiloBundle.message("settings.models.modeModels.title"),
KiloBundle.message("settings.models.modeModels.description"),
)
}
}
private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() }
private fun title(value: String): String = value.replace('-', ' ').replace('_', ' ').replaceFirstChar { it.titlecase() }
@@ -2,9 +2,11 @@ package ai.kilocode.client.settings.profile
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.ui.SettingsPanel
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.SearchableConfigurable
import com.intellij.openapi.wm.IdeFocusManager
import kotlinx.coroutines.CoroutineScope
@@ -25,9 +27,10 @@ import javax.swing.JComponent
* and a link to the Kilo dashboard. This is a status/action panel — it
* has no persistent settings, so [isModified] always returns false.
*/
class UserProfileConfigurable : SearchableConfigurable {
class UserProfileConfigurable : SearchableConfigurable, Configurable.NoScroll {
private var ui: JComponent? = null
private var ui: ProfileUi? = null
private var shell: SettingsPanel? = null
private var scope: CoroutineScope? = null
private var watchJob: Job? = null
private var focus = false
@@ -36,12 +39,12 @@ class UserProfileConfigurable : SearchableConfigurable {
override fun getDisplayName(): String = KiloBundle.message("settings.profile.displayName")
override fun getPreferredFocusedComponent(): JComponent? = (ui as? ProfileUi)?.preferredFocus()
override fun getPreferredFocusedComponent(): JComponent? = ui?.preferredFocus()
override fun focusOn(label: String) {
if (label != FOCUS_ACCOUNT_COMBO) return
focus = true
val panel = ui as? ProfileUi ?: return
val panel = ui ?: return
requestFocus(panel)
}
@@ -49,10 +52,13 @@ class UserProfileConfigurable : SearchableConfigurable {
val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope = cs
val panel = buildPanel(cs)
val root = SettingsPanel()
root.setContent(panel)
ui = panel
shell = root
startWatching(cs, panel)
if (focus) requestFocus(panel)
return panel
return root
}
private fun requestFocus(panel: ProfileUi) {
@@ -93,10 +99,11 @@ class UserProfileConfigurable : SearchableConfigurable {
override fun disposeUIResources() {
// Dispose UI first to invalidate pending login attempts before scope cancellation.
// Capturing local refs before nulling fields so the EDT callback is self-contained.
val panel = ui as? ProfileUi
val panel = ui
val job = watchJob
val cs = scope
ui = null
shell = null
watchJob = null
scope = null
@@ -0,0 +1,23 @@
package ai.kilocode.client.settings.ui
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.ui.TitledSeparator
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
internal open class SettingsContentPanel : Stack(StackAxis.VERTICAL) {
fun section(title: String, description: String? = null): SettingsRows {
next(TitledSeparator(title))
if (description != null) {
next(JBLabel(description).apply {
foreground = UIUtil.getContextHelpForeground()
font = UiStyle.Fonts.small()
})
}
val rows = SettingsRows()
next(rows)
return rows
}
}
@@ -0,0 +1,51 @@
package ai.kilocode.client.settings.ui
import ai.kilocode.client.ui.LayeredOverlayPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import com.intellij.ui.components.JBScrollPane
import java.awt.BorderLayout
import java.awt.Rectangle
import javax.swing.JComponent
internal open class SettingsPanel : LayeredOverlayPanel() {
val top = SettingsTop()
val settings = Stack.vertical()
val progress = SettingsProgressOverlay()
init {
val body = Stack.vertical()
.next(top)
.gap(UiStyle.Gap.lg())
.next(settings)
content.add(JBScrollPane(body).apply { border = null }, BorderLayout.CENTER)
addOverlay(progress) { pane, child ->
val size = child.preferredSize
Rectangle(
((pane.width - size.width) / 2).coerceAtLeast(0),
UiStyle.Gap.pad(),
size.width,
size.height,
)
}
}
fun setContent(component: JComponent) {
settings.removeAll()
settings.next(component)
revalidate()
repaint()
}
fun showProgress(text: String) {
progress.showProgress(text)
overlay.revalidate()
overlay.repaint()
}
fun clearProgress() {
progress.clearProgress()
overlay.revalidate()
overlay.repaint()
}
}
@@ -0,0 +1,67 @@
package ai.kilocode.client.settings.ui
import ai.kilocode.client.ui.UiStyle
import com.intellij.codeInsight.hint.HintUtil
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import javax.swing.JPanel
internal class SettingsProgressOverlay : JPanel(BorderLayout()) {
private var label: JBLabel? = null
init {
val view = JBLabel()
label = view
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad())
add(view, BorderLayout.CENTER)
isVisible = false
syncColors()
}
fun showProgress(text: String) {
val view = requireNotNull(label)
if (view.text != text) view.text = text
if (!isVisible) isVisible = true
revalidate()
repaint()
}
fun clearProgress() {
val view = requireNotNull(label)
if (!isVisible && view.text.isNullOrBlank()) return
view.text = ""
isVisible = false
revalidate()
repaint()
}
override fun updateUI() {
super.updateUI()
syncColors()
}
private fun syncColors() {
val hint = HintUtil.getInformationHint()
background = hint.textBackground
foreground = hint.textForeground
label?.foreground = foreground
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = background
val arc = UiStyle.Arc.component()
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
}
@@ -6,8 +6,6 @@ import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.ui.JBColor
import com.intellij.ui.SeparatorComponent
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
@@ -18,38 +16,91 @@ import javax.swing.JPanel
class SettingsRow(
title: String,
description: String? = null,
value: JComponent,
value: JComponent? = null,
) : JPanel(BorderLayout()) {
private val titleLabel = JBLabel(title).apply { font = UiStyle.Fonts.bold() }
private val descriptionLabel = JBLabel(description.orEmpty()).apply {
font = UiStyle.Fonts.hint()
foreground = UIUtil.getContextHelpForeground()
isVisible = description != null
}
private val labels = Stack.vertical(UiStyle.Gap.sm())
private val valuePanel = JPanel(BorderLayout())
private var current: JComponent? = null
init {
border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0)
val labels = Stack.vertical(UiStyle.Gap.sm())
labels.next(JBLabel(title).apply { font = UiStyle.Fonts.bold() })
if (description != null) {
labels.next(JBLabel(description).apply {
font = UiStyle.Fonts.hint()
foreground = UIUtil.getContextHelpForeground()
})
}
valuePanel.isOpaque = false
labels.next(titleLabel)
labels.next(descriptionLabel)
add(labels, BorderLayout.CENTER)
add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.EAST)
add(valuePanel, BorderLayout.EAST)
setValue(value)
}
fun update(
title: String,
description: String? = null,
value: JComponent? = null,
) {
if (titleLabel.text != title) titleLabel.text = title
val text = description.orEmpty()
if (descriptionLabel.text != text) descriptionLabel.text = text
val visible = description != null
if (descriptionLabel.isVisible != visible) descriptionLabel.isVisible = visible
setValue(value)
}
private fun setValue(value: JComponent?) {
if (value != null && current !== value) {
valuePanel.removeAll()
valuePanel.add(value.align(HAlign.RIGHT, VAlign.CENTER), BorderLayout.CENTER)
current = value
}
}
}
class SettingsRows : Stack(StackAxis.VERTICAL) {
private var rows = 0
private val keyed = linkedMapOf<String, SettingsRow>()
fun row(child: SettingsRow): SettingsRows {
if (rows > 0) next(SeparatorComponent(0, JBColor.border(), null))
next(child)
rows += 1
return this
}
fun row(key: String, child: SettingsRow): SettingsRow {
keyed.remove(key)?.let { remove(it) }
keyed[key] = child
next(child)
return child
}
fun update(
key: String,
title: String,
description: String? = null,
value: JComponent? = null,
): SettingsRow? {
val row = keyed[key] ?: return null
row.update(title, description, value)
return row
}
fun remove(key: String): SettingsRow? {
val row = keyed.remove(key) ?: return null
remove(row)
revalidate()
repaint()
return row
}
fun retain(keys: Set<String>) {
keyed.keys.toList().filter { it !in keys }.forEach { remove(it) }
}
override fun removeAll() {
rows = 0
keyed.clear()
super.removeAll()
}
}
@@ -0,0 +1,89 @@
package ai.kilocode.client.settings.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.ui.EditorNotificationPanel
import com.intellij.ui.InlineBanner
import java.awt.BorderLayout
import javax.swing.JComponent
import javax.swing.JPanel
internal data class SettingsAction(
val text: String,
val run: (JComponent) -> Unit,
)
internal enum class SettingsBannerKind { WARNING, ERROR }
internal class SettingsTop : Stack(StackAxis.VERTICAL, UiStyle.Gap.md()) {
private val slot = JPanel(BorderLayout())
private var spec: BannerSpec? = null
init {
slot.isOpaque = false
slot.isVisible = false
next(slot)
isVisible = false
}
fun showBanner(
text: String,
actions: List<SettingsAction>,
kind: SettingsBannerKind = SettingsBannerKind.WARNING,
) {
val next = BannerSpec(text, actions.map { it.text }, kind)
if (spec != next) {
val banner = InlineBanner(text, status(kind)).showCloseButton(false)
actions.forEach { action ->
banner.addAction(action.text, Runnable { action.run(banner) })
}
slot.removeAll()
slot.add(banner, BorderLayout.CENTER)
spec = next
}
show(slot)
}
fun showNotLoggedIn(run: (JComponent) -> Unit) {
showBanner(
KiloBundle.message("settings.login.message"),
listOf(SettingsAction(KiloBundle.message("settings.login.action"), run)),
)
}
fun hideBanner() {
if (!slot.isVisible) return
slot.isVisible = false
sync(layout = true)
}
private fun show(component: JComponent) {
if (component.isVisible) {
sync(layout = false)
return
}
component.isVisible = true
sync(layout = true)
}
private fun sync(layout: Boolean) {
val visible = slot.isVisible
val changed = isVisible != visible
isVisible = visible
if (changed || layout) parent?.revalidate()
parent?.repaint()
}
private fun status(kind: SettingsBannerKind) = when (kind) {
SettingsBannerKind.WARNING -> EditorNotificationPanel.Status.Warning
SettingsBannerKind.ERROR -> EditorNotificationPanel.Status.Error
}
private data class BannerSpec(
val text: String,
val actions: List<String>,
val kind: SettingsBannerKind,
)
}
@@ -0,0 +1,158 @@
package ai.kilocode.client.ui
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.Container
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JComponent
import javax.swing.JLayeredPane
import javax.swing.JPanel
open class LayeredOverlayPanel(
content: JPanel = BorderLayoutPanel(),
overlay: Overlay = Overlay(),
blocker: Blocker = Blocker(),
) : JLayeredPane() {
private val baseContent = content
private val baseOverlay = overlay
private val baseBlocker = blocker
open val content: JPanel get() = baseContent
open val overlay: Overlay get() = baseOverlay
open val blocker: Blocker get() = baseBlocker
init {
layout = null
add(baseContent)
setLayer(baseContent, DEFAULT_LAYER)
add(baseOverlay)
setLayer(baseOverlay, PALETTE_LAYER)
add(baseBlocker)
setLayer(baseBlocker, MODAL_LAYER)
baseBlocker.isVisible = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
overlay.addOverlay(child, bounds)
}
@RequiresEdt
fun setModalContent(child: JComponent?) {
blocker.removeAll()
if (child != null) blocker.add(child.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
blocker.isVisible = child != null
if (child != null) blocker.requestFocusInWindow()
invalidate()
blocker.invalidate()
child?.invalidate()
if (width > 0 && height > 0) {
doLayout()
child?.let(::layoutTree)
}
blocker.revalidate()
blocker.repaint()
revalidate()
repaint()
}
@RequiresEdt
fun setBlocked(value: Boolean) {
blocker.isVisible = value
if (value) blocker.requestFocusInWindow()
invalidate()
blocker.invalidate()
if (width > 0 && height > 0) doLayout()
revalidate()
repaint()
}
override fun doLayout() {
components
.sortedBy { getLayer(it) }
.forEach { child ->
child.setBounds(0, 0, width, height)
child.doLayout()
}
}
override fun getPreferredSize(): Dimension {
val w = listOf(content, overlay).maxOfOrNull { it.preferredSize.width } ?: 0
val h = listOf(content, overlay).maxOfOrNull { it.preferredSize.height } ?: 0
return JBDimension(w, h)
}
open class Overlay : BorderLayoutPanel() {
private val items = linkedMapOf<JComponent, (JPanel, JComponent) -> Rectangle>()
init {
layout = null
isOpaque = false
}
fun addOverlay(child: JComponent, bounds: (JPanel, JComponent) -> Rectangle) {
items[child] = bounds
add(child)
}
override fun contains(x: Int, y: Int): Boolean {
for (child in components) {
if (child.isVisible && child.bounds.contains(x, y)) return true
}
return false
}
override fun doLayout() {
items.forEach { (child, bounds) ->
child.bounds = bounds(this, child)
child.doLayout()
}
}
override fun getPreferredSize(): Dimension {
val pref = super.getPreferredSize()
val w = maxOf(pref.width, components.maxOfOrNull { it.preferredSize.width } ?: 0)
val h = maxOf(pref.height, components.maxOfOrNull { it.preferredSize.height } ?: 0)
return JBDimension(w, h)
}
}
open class Blocker : JPanel() {
init {
layout = BorderLayout()
isFocusable = true
}
override fun updateUI() {
super.updateUI()
background = UiStyle.Colors.bg()
isOpaque = true
}
override fun contains(x: Int, y: Int): Boolean {
if (!isVisible) return false
return super.contains(x, y)
}
override fun doLayout() {
super.doLayout()
components.forEach { layoutTree(it) }
}
}
}
private fun layoutTree(comp: java.awt.Component) {
comp.doLayout()
if (comp is Container) comp.components.forEach { layoutTree(it) }
}
@@ -191,6 +191,8 @@ action.Kilo.ToolWindowToolbar.text=Kilo Toolbar
settings.kilo.displayName=Kilo Code
settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings.
settings.models.displayName=Models
settings.login.message=Sign in to Kilo Code to access account-backed features and manage billing.
settings.login.action=Open User Profile
settings.models.defaultModel.title=Default Model
settings.models.defaultModel.description=The model Kilo uses when no mode-specific model is configured.
settings.models.smallModel.title=Small Model
@@ -0,0 +1,145 @@
package ai.kilocode.client.settings.ui
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.codeInsight.hint.HintUtil
import com.intellij.ui.SeparatorComponent
import com.intellij.ui.components.JBLabel
import ai.kilocode.client.ui.UiStyle
import java.awt.Container
import java.awt.Rectangle
import javax.swing.AbstractButton
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JLabel
import javax.swing.text.JTextComponent
class SettingsRowsTest : BasePlatformTestCase() {
fun `test rows do not insert separators`() {
val rows = SettingsRows()
rows.row(SettingsRow("One", value = JButton("A")))
rows.row(SettingsRow("Two", value = JButton("B")))
assertEquals(2, rows.componentCount)
assertTrue(components(rows).none { it is SeparatorComponent })
}
fun `test keyed update preserves row and value`() {
val rows = SettingsRows()
val value = JButton("A")
val row = rows.row("one", SettingsRow("One", "Before", value))
val updated = rows.update("one", "Updated", "After", value)
assertSame(row, updated)
assertSame(value, components(row).first { it === value })
assertTrue(text(row).contains("Updated"))
assertTrue(text(row).contains("After"))
}
fun `test removing keyed row removes only that row`() {
val rows = SettingsRows()
val one = rows.row("one", SettingsRow("One", value = JButton("A")))
val two = rows.row("two", SettingsRow("Two", value = JButton("B")))
assertSame(one, rows.remove("one"))
assertEquals(1, rows.componentCount)
assertSame(two, rows.getComponent(0))
}
fun `test retain keeps requested keyed rows`() {
val rows = SettingsRows()
val one = rows.row("one", SettingsRow("One", value = JButton("A")))
rows.row("two", SettingsRow("Two", value = JButton("B")))
rows.retain(setOf("one"))
assertEquals(1, rows.componentCount)
assertSame(one, rows.getComponent(0))
}
fun `test top banner renders login action`() {
val top = SettingsTop()
top.showNotLoggedIn {}
assertTrue(text(top).contains("Sign in to Kilo Code"))
assertTrue(top.isVisible)
}
fun `test settings panel keeps banner in scroll content and progress in overlay`() {
val panel = SettingsPanel()
panel.top.showNotLoggedIn {}
panel.showProgress("Loading models...")
assertTrue(text(panel.content).contains("Sign in to Kilo Code"))
assertFalse(text(panel.overlay).contains("Sign in to Kilo Code"))
assertTrue(panel.overlay.components.any { it === panel.progress })
assertTrue(text(panel.progress).contains("Loading models..."))
}
fun `test settings progress overlay is centered near top`() {
val panel = SettingsPanel().apply { setSize(400, 300) }
panel.showProgress("Loading")
panel.doLayout()
val size = panel.progress.preferredSize
assertEquals(
Rectangle((400 - size.width) / 2, UiStyle.Gap.pad(), size.width, size.height),
panel.progress.bounds,
)
assertTrue(panel.overlay.contains(panel.progress.x + 1, panel.progress.y + 1))
assertFalse(panel.overlay.contains(1, 1))
}
fun `test settings progress overlay retains label across updates`() {
val panel = SettingsPanel()
panel.showProgress("Loading")
val label = components(panel.progress).filterIsInstance<JBLabel>().single { it.text == "Loading" }
panel.showProgress("Saving")
assertSame(label, components(panel.progress).filterIsInstance<JBLabel>().single { it.text == "Saving" })
panel.clearProgress()
assertFalse(panel.progress.isVisible)
}
fun `test settings progress overlay uses information colors`() {
val panel = SettingsPanel()
val hint = HintUtil.getInformationHint()
panel.showProgress("Loading")
val label = components(panel.progress).filterIsInstance<JBLabel>().single { it.text == "Loading" }
assertEquals(hint.textBackground, panel.progress.background)
assertEquals(hint.textForeground, panel.progress.foreground)
assertEquals(hint.textForeground, label.foreground)
}
private fun text(component: JComponent): String = components(component)
.mapNotNull {
when (it) {
is JLabel -> it.text
is AbstractButton -> it.text
is JTextComponent -> it.text
else -> null
}
}
.joinToString("\n")
private fun components(component: JComponent): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
fun visit(c: java.awt.Component) {
out += c
if (c is Container) c.components.forEach { visit(it) }
}
visit(component)
return out
}
}
@@ -0,0 +1,116 @@
package ai.kilocode.client.ui
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Dimension
import java.awt.Rectangle
import javax.swing.JLayeredPane
@Suppress("UnstableApiUsage")
class LayeredOverlayPanelTest : BasePlatformTestCase() {
fun `test root owns content overlay and blocker layers`() {
val root = LayeredOverlayPanel()
assertEquals(3, root.componentCount)
assertSame(root.content, root.components.first { it === root.content })
assertSame(root.overlay, root.components.first { it === root.overlay })
assertSame(root.blocker, root.components.first { it === root.blocker })
assertEquals(JLayeredPane.DEFAULT_LAYER, root.getLayer(root.content))
assertEquals(JLayeredPane.PALETTE_LAYER, root.getLayer(root.overlay))
assertEquals(JLayeredPane.MODAL_LAYER, root.getLayer(root.blocker))
}
fun `test root layout fills all immediate children`() {
val root = LayeredOverlayPanel().apply { setSize(320, 180) }
root.doLayout()
assertEquals(Rectangle(0, 0, 320, 180), root.content.bounds)
assertEquals(Rectangle(0, 0, 320, 180), root.overlay.bounds)
assertEquals(Rectangle(0, 0, 320, 180), root.blocker.bounds)
}
fun `test root preferred size is max of content and overlay`() {
val root = LayeredOverlayPanel().apply {
content.preferredSize = Dimension(300, 120)
overlay.preferredSize = Dimension(180, 220)
}
assertEquals(Dimension(300, 220), root.preferredSize)
}
fun `test addOverlay applies callback bounds and delegates child layout`() {
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
val child = Probe()
root.addOverlay(child) { _, item ->
Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height)
}
root.doLayout()
assertEquals(Rectangle(12, 34, 80, 24), child.bounds)
assertTrue(child.laid)
}
fun `test overlay contains only visible overlay children`() {
val root = LayeredOverlayPanel().apply { setSize(400, 260) }
val child = Probe()
root.addOverlay(child) { _, item -> Rectangle(12, 34, item.preferredSize.width, item.preferredSize.height) }
root.doLayout()
assertTrue(root.overlay.contains(20, 40))
assertFalse(root.overlay.contains(4, 4))
child.isVisible = false
assertFalse(root.overlay.contains(20, 40))
}
fun `test modal content is centered inside blocker`() {
val root = LayeredOverlayPanel().apply { setSize(200, 100) }
val child = Probe()
root.setModalContent(child)
root.doLayout()
assertTrue(root.blocker.isVisible)
assertEquals(1, root.blocker.componentCount)
assertEquals(Rectangle(60, 38, 80, 24), child.bounds)
}
fun `test clearing modal content hides and removes blocker children`() {
val root = LayeredOverlayPanel().apply { setSize(200, 100) }
root.setModalContent(Probe())
root.doLayout()
root.setModalContent(null)
assertFalse(root.blocker.isVisible)
assertEquals(0, root.blocker.componentCount)
}
fun `test blocker contains reflects visibility`() {
val root = LayeredOverlayPanel().apply { setSize(200, 100) }
root.doLayout()
root.setBlocked(false)
assertFalse(root.blocker.contains(50, 50))
root.setBlocked(true)
assertTrue(root.blocker.contains(50, 50))
}
private class Probe : BorderLayoutPanel() {
var laid = false
init {
preferredSize = Dimension(80, 24)
}
override fun doLayout() {
laid = true
super.doLayout()
}
}
}