diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt index 2c962db13b0..ba03b2f1bb4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionRootPanel.kt @@ -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 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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt index 6045f58c3f5..50e2e784977 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt index 77327d63bbe..19544d82447 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt @@ -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() + 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() 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() + + 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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt index 70cc62339e7..44cfd7a1c38 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt new file mode 100644 index 00000000000..9a661d69922 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsContentPanel.kt @@ -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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt new file mode 100644 index 00000000000..2e5293a482d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsPanel.kt @@ -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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt new file mode 100644 index 00000000000..cff8c3b95eb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsProgressOverlay.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt index 9ad34bc37a1..9a37e556629 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsRow.kt @@ -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() 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) { + keyed.keys.toList().filter { it !in keys }.forEach { remove(it) } + } + override fun removeAll() { - rows = 0 + keyed.clear() super.removeAll() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt new file mode 100644 index 00000000000..e34c7a8b01b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/ui/SettingsTop.kt @@ -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, + 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, + val kind: SettingsBannerKind, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt new file mode 100644 index 00000000000..311bee99c90 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -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 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) } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index de0755a1ae2..0e8fd0b6f2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt new file mode 100644 index 00000000000..b707c2c2c01 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/ui/SettingsRowsTest.kt @@ -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().single { it.text == "Loading" } + + panel.showProgress("Saving") + + assertSame(label, components(panel.progress).filterIsInstance().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().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 { + val out = mutableListOf() + fun visit(c: java.awt.Component) { + out += c + if (c is Container) c.components.forEach { visit(it) } + } + visit(component) + return out + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt new file mode 100644 index 00000000000..bd497a2c495 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/LayeredOverlayPanelTest.kt @@ -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() + } + } +}