fix(jetbrains): stabilize provider settings actions

This commit is contained in:
kirillk
2026-06-11 18:49:45 -04:00
parent b6bbb839e6
commit ef7aa7fdf8
15 changed files with 1133 additions and 196 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix JetBrains provider settings after OAuth/connect actions by waiting through transient backend reloads and allowing longer OAuth exchanges.
@@ -42,6 +42,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.JsonNull
@@ -92,6 +93,7 @@ class KiloBackendAppService private constructor(
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
private const val APP_LOAD_TIMEOUT_MS = 30_000L
private const val READY_TIMEOUT_MS = 5_000L
/** Test factory — no IntelliJ deps needed. */
internal fun create(
@@ -214,6 +216,25 @@ class KiloBackendAppService private constructor(
}
}
suspend fun awaitReady(timeoutMs: Long = READY_TIMEOUT_MS) {
when (_appState.value) {
is KiloAppState.Ready -> return
is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required")
is KiloAppState.Loading,
KiloAppState.Connecting -> {
val state = withTimeoutOrNull(timeoutMs) {
appState.first { it !is KiloAppState.Loading && it !is KiloAppState.Connecting }
}
when (state) {
is KiloAppState.Ready -> return
is KiloAppState.MigrationRequired -> throw IllegalStateException("Migration required")
else -> throw IllegalStateException("Kilo backend is not ready")
}
}
else -> throw IllegalStateException("Kilo backend is not ready")
}
}
suspend fun updateConfig(patch: ConfigPatchDto): KiloAppState {
val http = connection.apiClient ?: throw IllegalStateException("Not connected")
val current = _appState.value as? KiloAppState.Ready ?: throw IllegalStateException("Kilo backend is not ready")
@@ -39,12 +39,13 @@ internal class KiloBackendProviderSettingsManager(
.callTimeout(15, TimeUnit.SECONDS)
.build()
private const val CALL_TIMEOUT_SECONDS = 15L
private const val OAUTH_CALL_TIMEOUT_SECONDS = 60L
}
suspend fun state(directory: String): ProviderSettingsDto {
val start = System.currentTimeMillis()
LOG.debug { "provider settings state: start dir=$directory" }
app.requireReady()
app.awaitReady()
val errors = mutableListOf<LoadErrorDto>()
val providers = load("providers", errors) {
KiloCliDataParser.parseProviderSettingsProviders(get("/provider?directory=${enc(directory)}"))
@@ -90,14 +91,14 @@ internal class KiloBackendProviderSettingsManager(
suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto {
val body = KiloCliDataParser.buildProviderOAuthJson(input.method, input.inputs)
val raw = post("/provider/${enc(input.providerId)}/oauth/authorize?directory=${enc(input.directory)}", body)
val raw = post("/provider/${enc(input.providerId)}/oauth/authorize?directory=${enc(input.directory)}", body, OAUTH_CALL_TIMEOUT_SECONDS)
val parsed = KiloCliDataParser.parseOAuthReady(raw)
return ProviderOAuthReadyDto(parsed.first, parsed.second, parsed.third)
}
suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto {
val body = KiloCliDataParser.buildProviderOAuthJson(input.method, code = input.code)
post("/provider/${enc(input.providerId)}/oauth/callback?directory=${enc(input.directory)}", body)
post("/provider/${enc(input.providerId)}/oauth/callback?directory=${enc(input.directory)}", body, OAUTH_CALL_TIMEOUT_SECONDS)
dispose()
return ProviderActionResultDto(state(input.directory))
}
@@ -190,7 +191,7 @@ internal class KiloBackendProviderSettingsManager(
}
private suspend fun get(path: String) = request(Request.Builder().url(url(path)).get().build())
private suspend fun post(path: String, body: String) = request(Request.Builder().url(url(path)).post(body.toRequestBody(JSON)).build())
private suspend fun post(path: String, body: String, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS) = request(Request.Builder().url(url(path)).post(body.toRequestBody(JSON)).build(), timeoutSeconds)
private suspend fun put(path: String, body: String) = request(Request.Builder().url(url(path)).put(body.toRequestBody(JSON)).build())
private suspend fun patch(body: String) = request(Request.Builder().url(url("/global/config")).patch(body.toRequestBody(JSON)).build())
@@ -204,12 +205,12 @@ internal class KiloBackendProviderSettingsManager(
.onFailure { LOG.debug { "Provider settings dispose skipped: ${it.message}" } }
}
private suspend fun request(request: Request): String {
private suspend fun request(request: Request, timeoutSeconds: Long = CALL_TIMEOUT_SECONDS): String {
val start = System.currentTimeMillis()
LOG.debug { "provider settings http: start ${request.method} ${request.url.encodedPath}" }
val http = app.http?.newBuilder()
?.callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
?.readTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
?.callTimeout(timeoutSeconds, TimeUnit.SECONDS)
?.readTimeout(timeoutSeconds, TimeUnit.SECONDS)
?.build() ?: throw IllegalStateException("Kilo HTTP client is unavailable")
return withContext(Dispatchers.IO) {
try {
@@ -6,18 +6,25 @@ import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.ProviderDisconnectDto
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.CountDownLatch
import kotlin.system.measureTimeMillis
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloBackendProviderSettingsManagerTest {
@@ -75,12 +82,74 @@ class KiloBackendProviderSettingsManagerTest {
assertEquals(1, mock.requestCount("/global/dispose"))
}
@Test
fun `state waits through dispose triggered reload`() = runBlocking {
mock.providers = """{
"all":[{"id":"openai","name":"OpenAI","source":"custom","models":{}}],
"default":{},
"connected":["openai"],
"failed":[]
}""".trimIndent()
val app = app()
val manager = KiloBackendProviderSettingsManager(app)
assertTrue(mock.awaitSseConnection())
val gate = CountDownLatch(1)
mock.responseGate = gate
try {
mock.pushEvent("global.disposed", "{}")
withTimeout(5_000) {
app.appState.first { it is KiloAppState.Loading }
}
val state = async { manager.state("/test") }
delay(200)
assertFalse(state.isCompleted)
gate.countDown()
val result = withTimeout(10_000) { state.await() }
assertEquals(listOf("openai"), result.connected)
assertEquals(1, result.providers.size)
} finally {
mock.responseGate = null
gate.countDown()
}
}
@Test
fun `awaitReady returns immediately when ready`() = runBlocking {
val app = app()
val elapsed = measureTimeMillis {
app.awaitReady()
}
assertTrue(elapsed < 500, "awaitReady should not wait when already ready, elapsed=${elapsed}ms")
}
@Test
fun `awaitReady fails fast when disconnected`() = runBlocking {
val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog())
val elapsed = measureTimeMillis {
assertFailsWith<IllegalStateException> {
app.awaitReady()
}
}
assertTrue(elapsed < 500, "awaitReady should fail fast when disconnected, elapsed=${elapsed}ms")
}
private suspend fun manager(): KiloBackendProviderSettingsManager {
return KiloBackendProviderSettingsManager(app())
}
private suspend fun app(): KiloBackendAppService {
val app = KiloBackendAppService.create(scope, FakeCliServer(mock), TestLog())
app.connect()
withTimeout(10_000) {
app.appState.first { it is KiloAppState.Ready }
}
return KiloBackendProviderSettingsManager(app)
return app
}
}
@@ -19,6 +19,7 @@ import ai.kilocode.rpc.dto.ProviderSettingsDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import fleet.rpc.client.durable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withTimeout
@@ -32,14 +33,15 @@ class KiloProviderService internal constructor(
companion object {
private val LOG = KiloLog.create(KiloProviderService::class.java)
private const val RPC_TIMEOUT_MS = 20_000L
private const val OAUTH_RPC_TIMEOUT_MS = 90_000L
}
private suspend fun <T> call(name: String, block: suspend KiloProviderRpcApi.() -> T): T {
private suspend fun <T> call(name: String, timeoutMs: Long = RPC_TIMEOUT_MS, block: suspend KiloProviderRpcApi.() -> T): T {
val start = System.currentTimeMillis()
LOG.info("provider settings rpc $name: start")
val api = rpc
return try {
val result = withTimeout(RPC_TIMEOUT_MS) {
val result = withTimeout(timeoutMs) {
if (api != null) block(api) else durable { block(KiloProviderRpcApi.getInstance()) }
}
LOG.info("provider settings rpc $name: completed durationMs=${System.currentTimeMillis() - start}")
@@ -58,16 +60,23 @@ class KiloProviderService internal constructor(
}
suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto = action(input.directory) { connect(input) }
suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto = call("authorize provider=${input.providerId}") { authorize(input) }
suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto = action(input.directory) { callback(input) }
suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto = call("authorize provider=${input.providerId}", OAUTH_RPC_TIMEOUT_MS) { authorize(input) }
suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto = action(input.directory, OAUTH_RPC_TIMEOUT_MS) { callback(input) }
suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto = action(input.directory) { disconnect(input) }
suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto = action(input.directory) { enable(input) }
suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto = action(input.directory) { saveCustom(input) }
suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto = call("fetch custom models") { fetchCustomModels(input) }
private suspend fun action(directory: String, block: suspend KiloProviderRpcApi.() -> ProviderActionResultDto): ProviderActionResultDto {
private suspend fun action(directory: String, timeoutMs: Long = RPC_TIMEOUT_MS, block: suspend KiloProviderRpcApi.() -> ProviderActionResultDto): ProviderActionResultDto {
LOG.info("provider settings action: start dir=$directory")
val result = call("action dir=$directory", block)
val result = try {
call("action dir=$directory", timeoutMs, block)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LOG.warn("provider settings action failed for directory=$directory", e)
return ProviderActionResultDto(state(directory), error = e.message)
}
service<KiloWorkspaceService>().reload(directory)
service<KiloAppService>().refreshProfileAsync()
LOG.info("provider settings action: completed dir=$directory")
@@ -0,0 +1,44 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.ui.LayeredOverlayPanel
import ai.kilocode.client.ui.UiStyle
import java.awt.Rectangle
internal open class SettingsOverlayPanel : LayeredOverlayPanel() {
val progress = SettingsProgressOverlay()
init {
addOverlay(progress) { pane, child ->
val size = child.preferredSize
Rectangle(
((pane.width - size.width) / 2).coerceAtLeast(0),
UiStyle.Gap.pad(),
size.width,
size.height,
)
}
}
fun showProgress(text: String) {
progress.showProgress(text)
syncOverlay()
}
fun showError(text: String) {
progress.showError(text)
syncOverlay()
}
fun clearProgress() {
progress.clearProgress()
syncOverlay()
}
private fun syncOverlay() {
overlay.revalidate()
overlay.repaint()
content.repaint()
revalidate()
repaint()
}
}
@@ -1,6 +1,5 @@
package ai.kilocode.client.settings.base
import ai.kilocode.client.ui.LayeredOverlayPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
@@ -12,10 +11,9 @@ import javax.swing.JComponent
import javax.swing.ScrollPaneConstants
import javax.swing.Scrollable
internal open class SettingsPanel : LayeredOverlayPanel() {
internal open class SettingsPanel : SettingsOverlayPanel() {
val top = SettingsTop()
val settings = Stack.vertical()
val progress = SettingsProgressOverlay()
init {
val body = SettingsBody()
@@ -26,15 +24,6 @@ internal open class SettingsPanel : LayeredOverlayPanel() {
border = null
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
}, 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) {
@@ -44,23 +33,6 @@ internal open class SettingsPanel : LayeredOverlayPanel() {
repaint()
}
fun showProgress(text: String) {
progress.showProgress(text)
overlay.revalidate()
overlay.repaint()
}
fun showError(text: String) {
progress.showError(text)
overlay.revalidate()
overlay.repaint()
}
fun clearProgress() {
progress.clearProgress()
overlay.revalidate()
overlay.repaint()
}
}
private class SettingsBody : Stack(StackAxis.VERTICAL), Scrollable {
@@ -0,0 +1,29 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
import ai.kilocode.rpc.dto.ProviderSettingsDto
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
internal val POPULAR_PROVIDER_IDS = listOf("kilo", "anthropic", "deepseek", "openai", "google", "openrouter", "vercel")
internal fun isPopularProvider(id: String) = id in POPULAR_PROVIDER_IDS
internal fun popularProviderIndex(id: String): Int {
val index = POPULAR_PROVIDER_IDS.indexOf(id)
return if (index >= 0) index else Int.MAX_VALUE
}
internal fun providerDescription(provider: ProviderSettingsProviderDto): String {
val source = provider.source ?: "catalog"
val models = provider.models.size
return "$source · $models models"
}
internal fun providerMethods(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto): List<ProviderAuthMethodDto> {
val methods = state.auth[provider.id]
if (!methods.isNullOrEmpty()) return methods
return listOf(ProviderAuthMethodDto("api", "API key"))
}
internal fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set<String>) =
provider.id in ids || provider.key != null || provider.source == "config" || provider.id in state.config
@@ -0,0 +1,159 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.CollectionListModel
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.Point
import java.awt.Rectangle
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
import javax.swing.UIManager
private const val ACTION_GAP = 8
internal class ProviderListRenderer(
private val model: CollectionListModel<ProviderListRow>,
) : JPanel(BorderLayout()), ListCellRenderer<ProviderListRow> {
companion object {
fun actionAt(list: JList<*>, bounds: Rectangle, point: Point, row: ProviderListRow): ProviderListAction? {
val height = buttonHeight(list)
val top = bounds.y + (bounds.height - height) / 2
if (point.y !in top..(top + height)) return null
var edge = bounds.x + bounds.width - UiStyle.Gap.pad()
for (action in row.actions.asReversed()) {
val width = buttonWidth(list, action)
val left = edge - width
if (point.x in left..edge) return action.takeIf(row::enabled)
edge = left - JBUI.scale(ACTION_GAP)
}
return null
}
internal fun actionBounds(list: JList<*>, bounds: Rectangle, row: ProviderListRow): Map<ProviderListAction, Rectangle> {
val height = buttonHeight(list)
val top = bounds.y + (bounds.height - height) / 2
var edge = bounds.x + bounds.width - UiStyle.Gap.pad()
val out = linkedMapOf<ProviderListAction, Rectangle>()
for (action in row.actions.asReversed()) {
val width = buttonWidth(list, action)
val left = edge - width
out[action] = Rectangle(left, top, width, height)
edge = left - JBUI.scale(ACTION_GAP)
}
return out
}
private fun buttonWidth(list: JList<*>, action: ProviderListAction): Int {
val text = text(action)
val metrics = list.getFontMetrics(list.font)
return metrics.stringWidth(text) + UiStyle.Gap.pad() * 2
}
private fun buttonHeight(list: JList<*>): Int {
val metrics = list.getFontMetrics(list.font)
return metrics.height + UiStyle.Gap.sm() * 2
}
internal fun text(action: ProviderListAction) = when (action) {
ProviderListAction.CONNECT -> KiloBundle.message("settings.providers.connect")
ProviderListAction.OAUTH -> KiloBundle.message("settings.providers.oauth")
ProviderListAction.DISCONNECT -> KiloBundle.message("settings.providers.disconnect")
ProviderListAction.ENABLE -> KiloBundle.message("settings.providers.enable")
}
}
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
private val top = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty()
add(sep, BorderLayout.NORTH)
}
private val title = SimpleColoredComponent()
private val desc = JBLabel()
private val text = JPanel(BorderLayout()).apply {
add(title, BorderLayout.NORTH)
add(desc, BorderLayout.SOUTH)
}
private val actions = JPanel(FlowLayout(FlowLayout.RIGHT, JBUI.scale(ACTION_GAP), 0))
private val row = JPanel(BorderLayout()).apply {
add(text, BorderLayout.CENTER)
add(actions, BorderLayout.EAST)
}
private val wrap = PickerRow()
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row, title, text, desc, actions)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.pad(),
)
wrap.setContent(row)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
list: JList<out ProviderListRow>,
value: ProviderListRow,
index: Int,
selected: Boolean,
focused: Boolean,
): JPanel {
val focus = selected || list.hasFocus() || focused
val fg = UIUtil.getListForeground(selected, focus)
val weak = if (selected) fg else UiStyle.Colors.weak()
val current = model.items.getOrNull(index)
val section = if (current === value) providerListSectionTitle(model.items, index) else null
background = list.background
top.background = list.background
wrap.update(list, selected, focus)
sep.caption = section
sep.setHideLine(index == 0)
top.isVisible = section != null
title.clear()
title.append(value.provider.name, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
desc.text = providerDescription(value.provider)
desc.foreground = weak
actions.removeAll()
for (action in value.actions) {
actions.add(ActionLabel(action).apply {
isEnabled = value.enabled(action)
foreground = if (isEnabled) UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground()
else UIManager.getColor("Button.disabledText") ?: UIUtil.getContextHelpForeground()
background = UIManager.getColor("Button.background")
})
}
top.invalidate()
return this
}
internal fun actionTexts() = actions.components.filterIsInstance<JBLabel>().map { it.text }
private class ActionLabel(action: ProviderListAction) : JBLabel(text(action)) {
init {
horizontalAlignment = SwingConstants.CENTER
border = JBUI.Borders.compound(
JBUI.Borders.customLine(UIUtil.getBoundsColor()),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad()),
)
isOpaque = true
}
}
}
@@ -0,0 +1,74 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.model.ModelSearch
import ai.kilocode.rpc.dto.ProviderSettingsDto
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
internal enum class ProviderListAction {
CONNECT,
OAUTH,
DISCONNECT,
ENABLE,
}
internal data class ProviderListRow(
val provider: ProviderSettingsProviderDto,
val section: String,
val actions: List<ProviderListAction>,
) {
val key: String get() = provider.id
fun enabled(action: ProviderListAction) = action != ProviderListAction.DISCONNECT || provider.source != "env"
}
internal fun providerListRows(state: ProviderSettingsDto, query: String): List<ProviderListRow> {
val q = query.trim()
val ids = state.connected.toSet()
val disabled = state.disabled.toSet()
val filtered = state.providers.filter { ModelSearch.matches(q, it.name) }
val popular = filtered
.filter { it.id != "kilo" }
.filter { it.id !in disabled }
.filter { !configured(it, state, ids) }
.filter { isPopularProvider(it.id) }
.sortedWith(compareBy<ProviderSettingsProviderDto> { popularProviderIndex(it.id) }.thenBy { it.name.lowercase() }.thenBy { it.id })
val popularIds = popular.mapTo(mutableSetOf()) { it.id }
val all = filtered
.filter { it.id !in popularIds }
.sortedWith(compareBy<ProviderSettingsProviderDto> { it.name.lowercase() }.thenBy { it.id })
val rows = mutableListOf<ProviderListRow>()
rows += popular.map { ProviderListRow(it, KiloBundle.message("settings.providers.popular"), providerActions(it, state, disabled)) }
rows += all.map { ProviderListRow(it, KiloBundle.message("settings.providers.all"), providerActions(it, state, disabled)) }
return rows
}
internal fun providerListIndex(rows: List<ProviderListRow>, key: String?): Int {
if (key == null) return if (rows.isEmpty()) -1 else 0
return rows.indexOfFirst { it.key == key }
}
internal fun providerListIndex(rows: List<ProviderListRow>, index: Int): Int {
if (rows.isEmpty()) return -1
return index.coerceIn(0, rows.lastIndex)
}
internal fun providerListSectionTitle(rows: List<ProviderListRow>, index: Int): String? {
val row = rows.getOrNull(index) ?: return null
val prev = rows.getOrNull(index - 1)
return if (prev?.section != row.section) row.section else null
}
internal fun providerActions(
provider: ProviderSettingsProviderDto,
state: ProviderSettingsDto,
disabled: Set<String> = state.disabled.toSet(),
): List<ProviderListAction> {
if (provider.id in disabled) return listOf(ProviderListAction.ENABLE)
if (configured(provider, state, state.connected.toSet())) return listOf(ProviderListAction.DISCONNECT)
val methods = providerMethods(provider, state)
return buildList {
if (methods.any { it.type == "oauth" }) add(ProviderListAction.OAUTH)
if (methods.any { it.type == "api" }) add(ProviderListAction.CONNECT)
}
}
@@ -6,6 +6,7 @@ import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.SearchableConfigurable
import com.intellij.openapi.project.ProjectManager
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -19,7 +20,9 @@ class ProvidersConfigurable : SearchableConfigurable, Configurable.NoScroll {
override fun getId(): String = ID
override fun getDisplayName(): String = KiloBundle.message("settings.providers.displayName")
@RequiresEdt
override fun createComponent(): JComponent {
checkEdt()
val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope = cs
val dir = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }?.basePath.orEmpty()
@@ -30,27 +33,33 @@ class ProvidersConfigurable : SearchableConfigurable, Configurable.NoScroll {
override fun isModified(): Boolean = false
override fun apply() = Unit
override fun reset() = ui?.reload() ?: Unit
@RequiresEdt
override fun reset() {
checkEdt()
ui?.reload()
}
override fun disposeUIResources() {
val panel = ui
val cs = scope
ui = null
scope = null
cs?.cancel()
val app = ApplicationManager.getApplication()
if (panel != null && app.isDispatchThread) {
panel.dispose()
cs?.cancel()
return
}
if (panel != null) {
app.invokeLater({
panel.dispose()
cs?.cancel()
}, ModalityState.any())
return
}
cs?.cancel()
}
private fun checkEdt() {
check(ApplicationManager.getApplication().isDispatchThread) { "Provider configurable UI must run on EDT" }
}
companion object {
@@ -2,8 +2,7 @@ package ai.kilocode.client.settings.providers
import ai.kilocode.client.app.KiloProviderService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.settings.base.BaseContentPanel
import ai.kilocode.client.settings.base.SettingsRow
import ai.kilocode.client.settings.base.SettingsOverlayPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.log.KiloLog
@@ -19,138 +18,216 @@ import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto
import ai.kilocode.rpc.dto.ProviderSettingsDto
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.ui.CollectionListModel
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.SearchTextField
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBPasswordField
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextField
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComboBox
import javax.swing.JComponent
import javax.swing.DefaultListCellRenderer
import javax.swing.JPanel
import javax.swing.JList
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.ScrollPaneConstants
import javax.swing.event.DocumentEvent
class ProvidersSettingsUi(
private val edt = Dispatchers.EDT + ModalityState.any().asContextElement()
internal class ProvidersSettingsUi(
private val cs: CoroutineScope,
private val directory: String,
) : JPanel(BorderLayout()), Disposable {
) : SettingsOverlayPanel(), Disposable {
companion object {
val LOG = KiloLog.create(ProvidersSettingsUi::class.java)
}
private val content = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable, ::custom, ::reload)
private val scroll = JBScrollPane(content)
private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable, ::custom, ::reload)
private var state = ProviderSettingsDto()
private var job: Job? = null
private var request = 0
private var disposed = false
init {
add(scroll, BorderLayout.CENTER)
content.add(view, BorderLayout.CENTER)
reload()
}
@RequiresEdt
fun reload() {
checkEdt()
LOG.info("provider settings ui reload: start dir=$directory")
syncLoading()
launch("reload") {
launch("reload") { id ->
val next = service<KiloProviderService>().state(directory)
LOG.info("provider settings ui reload: state providers=${next.providers.size} errors=${next.errors.size}")
apply(next, null)
apply(id, next, null)
}
}
@RequiresEdt
private fun syncLoading() {
content.loading()
checkEdt()
showProgress(KiloBundle.message("settings.providers.loading"))
}
@RequiresEdt
private fun connect(provider: ProviderSettingsProviderDto) {
checkEdt()
val methods = state.auth[provider.id].orEmpty().filter { it.type == "api" }
val dialog = ApiKeyDialog(provider.name, methods.firstOrNull())
if (!dialog.showAndGet()) return
content.loading()
launch("connect provider=${provider.id}") {
val result = service<KiloProviderService>().connect(ProviderConnectDto(directory, provider.id, dialog.key(), dialog.metadata()))
apply(result.state, result.error)
val key = dialog.key()
val metadata = dialog.metadata()
syncLoading()
launch("connect provider=${provider.id}") { id ->
val result = service<KiloProviderService>().connect(ProviderConnectDto(directory, provider.id, key, metadata))
apply(id, result.state, result.error)
}
}
@RequiresEdt
private fun oauth(provider: ProviderSettingsProviderDto) {
checkEdt()
val methods = state.auth[provider.id].orEmpty().filter { it.type == "oauth" }
val method = state.auth[provider.id].orEmpty().indexOf(methods.firstOrNull()).coerceAtLeast(0).toString()
content.loading()
launch("authorize provider=${provider.id}") {
syncLoading()
launch("authorize provider=${provider.id}") { id ->
val ready = service<KiloProviderService>().authorize(ProviderOAuthAuthorizeDto(directory, provider.id, method))
val code = withContext(Dispatchers.Main) {
val code = withContext(edt) {
if (!active(id)) return@withContext null
ready.url?.let(BrowserUtil::browse)
if (ready.method == "code") Messages.showInputDialog(this@ProvidersSettingsUi, ready.instructions ?: "Enter OAuth code", provider.name, null) else null
}
val current = withContext(edt) { active(id) }
if (!current) return@launch
val result = service<KiloProviderService>().callback(ProviderOAuthCallbackDto(directory, provider.id, method, code))
apply(result.state, result.error)
apply(id, result.state, result.error)
}
}
@RequiresEdt
private fun disconnect(provider: ProviderSettingsProviderDto) {
content.loading()
launch("disconnect provider=${provider.id}") {
checkEdt()
syncLoading()
launch("disconnect provider=${provider.id}") { id ->
val result = service<KiloProviderService>().disconnect(ProviderDisconnectDto(directory, provider.id))
apply(result.state, result.error)
apply(id, result.state, result.error)
}
}
@RequiresEdt
private fun enable(provider: ProviderSettingsProviderDto) {
content.loading()
launch("enable provider=${provider.id}") {
checkEdt()
syncLoading()
launch("enable provider=${provider.id}") { id ->
val result = service<KiloProviderService>().enable(ProviderEnableDto(directory, provider.id))
apply(result.state, result.error)
apply(id, result.state, result.error)
}
}
@RequiresEdt
private fun custom() {
checkEdt()
val dialog = CustomProviderDialog()
if (!dialog.showAndGet()) return
content.loading()
launch("save custom provider") {
val result = service<KiloProviderService>().saveCustom(dialog.input(directory))
apply(result.state, result.error)
val input = dialog.input(directory)
syncLoading()
launch("save custom provider") { id ->
val result = service<KiloProviderService>().saveCustom(input)
apply(id, result.state, result.error)
}
}
private fun launch(name: String, block: suspend () -> Unit) {
cs.launch {
@RequiresEdt
private fun launch(name: String, block: suspend (Int) -> Unit) {
checkEdt()
val id = ++request
job?.cancel()
job = cs.launch {
val start = System.currentTimeMillis()
LOG.info("provider settings ui $name: coroutine start dir=$directory")
try {
block()
block(id)
LOG.info("provider settings ui $name: coroutine completed durationMs=${System.currentTimeMillis() - start}")
} catch (e: TimeoutCancellationException) {
LOG.warn("provider settings ui $name: coroutine timed out durationMs=${System.currentTimeMillis() - start}", e)
withContext(edt) {
if (!active(id)) return@withContext
showError("${e::class.simpleName}: ${e.message}")
}
} catch (e: CancellationException) {
LOG.info("provider settings ui $name: coroutine cancelled durationMs=${System.currentTimeMillis() - start}")
throw e
} catch (e: Exception) {
LOG.warn("provider settings ui $name: coroutine failed durationMs=${System.currentTimeMillis() - start}", e)
withContext(Dispatchers.Main) {
content.error("${e::class.simpleName}: ${e.message}")
withContext(edt) {
if (!active(id)) return@withContext
showError("${e::class.simpleName}: ${e.message}")
}
}
}
}
private suspend fun apply(next: ProviderSettingsDto, error: String?) {
withContext(Dispatchers.Main) {
private suspend fun apply(id: Int, next: ProviderSettingsDto, error: String?) {
withContext(edt) {
if (!active(id)) return@withContext
LOG.info("provider settings ui apply: start providers=${next.providers.size} errors=${next.errors.size} message=${error != null}")
state = next
content.update(next, error)
view.update(next)
val text = error ?: next.errors.joinToString("; ") { it.detail ?: it.resource }.takeIf { it.isNotBlank() }
if (text != null) showError(text) else clearProgress()
LOG.info("provider settings ui apply: completed providers=${next.providers.size}")
}
}
override fun dispose() = Unit
@RequiresEdt
override fun dispose() {
checkEdt()
disposed = true
request++
job?.cancel()
job = null
}
@RequiresEdt
private fun active(id: Int): Boolean {
checkEdt()
return !disposed && id == request
}
private fun checkEdt() {
check(ApplicationManager.getApplication().isDispatchThread) { "Provider settings UI updates must run on EDT" }
}
}
internal class ProvidersContent(
@@ -160,92 +237,132 @@ internal class ProvidersContent(
private val enable: (ProviderSettingsProviderDto) -> Unit,
private val custom: () -> Unit,
private val reload: () -> Unit,
) : BaseContentPanel() {
) : JPanel(BorderLayout()) {
private val top = Stack.horizontal(UiStyle.Gap.sm())
private val status = JBLabel("").apply { foreground = UIUtil.getContextHelpForeground() }
private val connected: ai.kilocode.client.settings.base.SettingsRows
private val available: ai.kilocode.client.settings.base.SettingsRows
private val disabled: ai.kilocode.client.settings.base.SettingsRows
private val model = CollectionListModel<ProviderListRow>()
private val list = JBList(model).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
emptyText.text = KiloBundle.message("settings.providers.noMatches")
}
private val search = SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("settings.providers.search")
}
private var state = ProviderSettingsDto()
init {
layout = BorderLayout()
border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad())
top.next(JButton(KiloBundle.message("settings.providers.addCustom")).apply { addActionListener { custom() } })
top.next(JButton(KiloBundle.message("settings.providers.refresh")).apply { addActionListener { reload() } })
next(top)
next(status)
connected = section(KiloBundle.message("settings.providers.connected"))
available = section(KiloBundle.message("settings.providers.available"))
disabled = section(KiloBundle.message("settings.providers.disabled"))
add(top, BorderLayout.NORTH)
list.cellRenderer = ProviderListRenderer(model)
list.registerKeyboardAction(
{ primary() },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.registerKeyboardAction(
{ primary() },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.registerKeyboardAction(
{ move(-1) },
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.registerKeyboardAction(
{ move(1) },
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
JComponent.WHEN_FOCUSED,
)
search.textEditor.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
sync()
}
})
list.addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
val idx = list.locationToIndex(e.point)
val bounds = idx.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return
if (!bounds.contains(e.point)) return
val row = model.getElementAt(idx)
val action = ProviderListRenderer.actionAt(list, bounds, e.point, row) ?: return
activate(row, action)
e.consume()
}
})
ScrollingUtil.installActions(list)
val body = JPanel(BorderLayout(0, UiStyle.Gap.sm()))
body.add(search, BorderLayout.NORTH)
body.add(ScrollPaneFactory.createScrollPane(list).apply {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
}, BorderLayout.CENTER)
add(body, BorderLayout.CENTER)
}
fun loading() {
status.text = KiloBundle.message("settings.providers.loading")
}
fun error(message: String) {
status.text = message
}
fun update(state: ProviderSettingsDto, error: String? = null) {
@RequiresEdt
fun update(state: ProviderSettingsDto) {
checkEdt()
ProvidersSettingsUi.LOG.info("provider settings content update: start providers=${state.providers.size} connected=${state.connected.size} disabled=${state.disabled.size}")
status.text = error ?: state.errors.joinToString("; ") { it.detail ?: it.resource }
val ids = state.connected.toSet()
val disabledIds = state.disabled.toSet()
val connectedKeys = mutableSetOf<String>()
val availableKeys = mutableSetOf<String>()
val disabledKeys = mutableSetOf<String>()
state.providers.sortedWith(compareBy<ProviderSettingsProviderDto> { it.name.lowercase() }.thenBy { it.id }).forEach { provider ->
val target = when {
provider.id in disabledIds -> disabled
configured(provider, state, ids) -> connected
else -> available
}
val keys = when (target) {
connected -> connectedKeys
available -> availableKeys
else -> disabledKeys
}
val key = provider.id
keys.add(key)
val row = SettingsRow(provider.name, description(provider), buttons(provider, state, disabledIds))
target.row(key, row)
this.state = state
sync()
ProvidersSettingsUi.LOG.info("provider settings content update: completed rows=${model.size}")
}
@RequiresEdt
private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null) {
checkEdt()
val rows = providerListRows(state, search.text)
model.replaceAll(rows)
val idx = at?.let { providerListIndex(rows, it) }?.takeIf { it >= 0 }
?: providerListIndex(rows, prefer).takeIf { it >= 0 }
?: rows.indices.firstOrNull()
?: -1
if (idx >= 0) choose(idx)
else list.clearSelection()
}
@RequiresEdt
private fun choose(idx: Int) {
checkEdt()
list.selectedIndex = idx
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
}
@RequiresEdt
private fun move(step: Int) {
checkEdt()
val size = model.size
if (size <= 0) return
val idx = ((list.selectedIndex.takeIf { it >= 0 } ?: 0) + step).coerceIn(0, size - 1)
choose(idx)
}
@RequiresEdt
private fun primary() {
checkEdt()
val row = list.selectedValue ?: return
val action = row.actions.firstOrNull() ?: return
activate(row, action)
}
@RequiresEdt
private fun activate(row: ProviderListRow, action: ProviderListAction) {
checkEdt()
if (!row.enabled(action)) return
when (action) {
ProviderListAction.CONNECT -> connect(row.provider)
ProviderListAction.OAUTH -> oauth(row.provider)
ProviderListAction.DISCONNECT -> disconnect(row.provider)
ProviderListAction.ENABLE -> enable(row.provider)
}
connected.retain(connectedKeys)
available.retain(availableKeys)
disabled.retain(disabledKeys)
ProvidersSettingsUi.LOG.info("provider settings content update: completed connected=${connectedKeys.size} available=${availableKeys.size} disabled=${disabledKeys.size}")
}
private fun description(provider: ProviderSettingsProviderDto): String {
val source = provider.source ?: "catalog"
val models = provider.models.size
return "$source · $models models"
private fun checkEdt() {
check(ApplicationManager.getApplication().isDispatchThread) { "Provider settings content updates must run on EDT" }
}
private fun buttons(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, disabled: Set<String>): JComponent {
val row = Stack.horizontal(UiStyle.Gap.sm())
if (provider.id in disabled) {
row.next(JButton(KiloBundle.message("settings.providers.enable")).apply { addActionListener { enable(provider) } })
return row
}
if (configured(provider, state, state.connected.toSet())) {
row.next(JButton(KiloBundle.message("settings.providers.disconnect")).apply { isEnabled = provider.source != "env"; addActionListener { disconnect(provider) } })
return row
}
val methods = methods(provider, state)
if (methods.any { it.type == "api" }) row.next(JButton(KiloBundle.message("settings.providers.connect")).apply { addActionListener { connect(provider) } })
if (methods.any { it.type == "oauth" }) row.next(JButton(KiloBundle.message("settings.providers.oauth")).apply { addActionListener { oauth(provider) } })
return row
}
private fun methods(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto): List<ProviderAuthMethodDto> {
val methods = state.auth[provider.id]
if (!methods.isNullOrEmpty()) return methods
return listOf(ProviderAuthMethodDto("api", "API key"))
}
private fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set<String>) =
provider.id in ids || provider.key != null || provider.source == "config" || provider.id in state.config
}
private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : DialogWrapper(true) {
@@ -260,8 +377,10 @@ private class ApiKeyDialog(title: String, method: ProviderAuthMethodDto?) : Dial
initValidation()
}
@RequiresEdt
fun key(): String = String(key.password)
@RequiresEdt
fun metadata(): Map<String, String> = fields.mapValues { (_, field) ->
when (field) {
is JComboBox<*> -> (field.selectedItem as? ProviderAuthOptionDto)?.value ?: field.selectedItem?.toString().orEmpty()
@@ -312,6 +431,7 @@ private class CustomProviderDialog : DialogWrapper(true) {
initValidation()
}
@RequiresEdt
fun input(directory: String) = CustomProviderSaveDto(
directory = directory,
id = id.text.trim(),
@@ -207,6 +207,10 @@ settings.providers.loading=Loading providers...
settings.providers.connected=Connected providers
settings.providers.available=Available providers
settings.providers.disabled=Disabled providers
settings.providers.popular=Popular providers
settings.providers.all=All providers
settings.providers.search=Filter providers
settings.providers.noMatches=No matching providers
settings.providers.addCustom=Add custom provider
settings.providers.refresh=Refresh
settings.providers.connect=Connect
@@ -1,78 +1,397 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.client.app.KiloProviderService
import ai.kilocode.client.testing.FakeProviderRpcApi
import ai.kilocode.rpc.dto.CustomProviderConfigDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.ProviderAuthMethodDto
import ai.kilocode.rpc.dto.ProviderDisconnectDto
import ai.kilocode.rpc.dto.ProviderSettingsDto
import ai.kilocode.rpc.dto.ProviderSettingsProviderDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.testFramework.replaceService
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.SearchTextField
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.Container
import javax.swing.AbstractButton
import java.awt.Point
import java.awt.Rectangle
import java.awt.image.BufferedImage
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.UIManager
@Suppress("UNCHECKED_CAST")
class ProvidersSettingsUiTest : BasePlatformTestCase() {
private var scope: CoroutineScope? = null
private var ui: ProvidersSettingsUi? = null
override fun tearDown() {
try {
val panel = ui
if (panel != null) edt { panel.dispose() }
ui = null
scope?.cancel()
scope = null
} finally {
super.tearDown()
}
}
fun `test catalog provider without auth methods is connectable`() {
val content = content()
content.update(
ProviderSettingsDto(
providers = listOf(provider("models-dev-provider", "Models Dev Provider", source = "custom")),
),
)
edt {
content.update(
ProviderSettingsDto(
providers = listOf(provider("models-dev-provider", "Models Dev Provider", source = "custom")),
),
)
}
val labels = buttons(content)
assertTrue(labels.contains("Connect"))
assertFalse(labels.contains("Disconnect"))
edt {
assertEquals(listOf(ProviderListAction.CONNECT), rows(content).single().actions)
assertFalse(rows(content).single().actions.contains(ProviderListAction.DISCONNECT))
}
}
fun `test catalog custom provider is connectable not disconnectable`() {
fun `test provider with api and oauth methods exposes both actions`() {
val content = content()
content.update(
ProviderSettingsDto(
providers = listOf(provider("cloudflare-ai-gateway", "Cloudflare AI Gateway", source = "custom")),
auth = mapOf(
"cloudflare-ai-gateway" to listOf(
ProviderAuthMethodDto("api", "API key"),
ProviderAuthMethodDto("oauth", "OAuth"),
edt {
content.update(
ProviderSettingsDto(
providers = listOf(provider("cloudflare-ai-gateway", "Cloudflare AI Gateway", source = "custom")),
auth = mapOf(
"cloudflare-ai-gateway" to listOf(
ProviderAuthMethodDto("api", "API key"),
ProviderAuthMethodDto("oauth", "OAuth"),
),
),
),
),
)
)
}
val labels = buttons(content)
assertTrue(labels.contains("Connect"))
assertTrue(labels.contains("OAuth"))
assertFalse(labels.contains("Disconnect"))
edt { assertEquals(listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), rows(content).single().actions) }
}
fun `test configured custom provider is disconnectable`() {
fun `test content uses border layout with toolbar north and list center`() {
val content = content()
edt {
val layout = content.layout as BorderLayout
val north = layout.getLayoutComponent(BorderLayout.NORTH) as Container
val center = layout.getLayoutComponent(BorderLayout.CENTER) as Container
assertEquals(listOf("Add custom provider", "Refresh"), components(north).filterIsInstance<JButton>().map { it.text })
assertEquals(1, components(center).filterIsInstance<SearchTextField>().size)
assertEquals(1, components(center).filterIsInstance<JBList<ProviderListRow>>().size)
}
}
fun `test configured custom provider exposes only disconnect`() {
val content = content()
content.update(
ProviderSettingsDto(
providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")),
config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = "@ai-sdk/openai-compatible")),
auth = mapOf("local-openai" to listOf(ProviderAuthMethodDto("api", "API key"))),
),
)
edt {
content.update(
ProviderSettingsDto(
providers = listOf(provider("local-openai", "Local OpenAI", source = "custom")),
config = mapOf("local-openai" to CustomProviderConfigDto("local-openai", npm = "@ai-sdk/openai-compatible")),
auth = mapOf("local-openai" to listOf(ProviderAuthMethodDto("api", "API key"))),
),
)
}
assertEquals(listOf("Disconnect"), buttons(content).filter { it in setOf("Connect", "OAuth", "Disconnect") })
edt { assertEquals(listOf(ProviderListAction.DISCONNECT), rows(content).single().actions) }
}
private fun content() = ProvidersContent({}, {}, {}, {}, {}, {})
fun `test popular rows use vscode order and exclude kilo`() {
val rows = providerListRows(
ProviderSettingsDto(
providers = listOf(
provider("openrouter", "OpenRouter"),
provider("kilo", "Kilo"),
provider("google", "Google"),
provider("anthropic", "Anthropic"),
provider("vercel", "Vercel"),
provider("openai", "OpenAI"),
provider("deepseek", "DeepSeek"),
),
),
"",
)
private fun provider(id: String, name: String, source: String) = ProviderSettingsProviderDto(
assertEquals(listOf("anthropic", "deepseek", "openai", "google", "openrouter", "vercel"), rows.take(6).map { it.key })
assertEquals("Popular providers", providerListSectionTitle(rows, 0))
assertEquals("All providers", providerListSectionTitle(rows, 6))
assertEquals("kilo", rows[6].key)
}
fun `test connected popular provider is not duplicated in popular section`() {
val rows = providerListRows(
ProviderSettingsDto(
providers = listOf(provider("anthropic", "Anthropic"), provider("openai", "OpenAI")),
connected = listOf("anthropic"),
),
"",
)
assertEquals(listOf("openai", "anthropic"), rows.map { it.key })
assertEquals("Popular providers", providerListSectionTitle(rows, 0))
assertEquals("All providers", providerListSectionTitle(rows, 1))
assertEquals(listOf(ProviderListAction.DISCONNECT), rows[1].actions)
}
fun `test disabled popular provider appears in all providers with enable`() {
val rows = providerListRows(
ProviderSettingsDto(
providers = listOf(provider("anthropic", "Anthropic"), provider("openai", "OpenAI")),
disabled = listOf("anthropic"),
),
"",
)
assertEquals(listOf("openai", "anthropic"), rows.map { it.key })
assertEquals("All providers", providerListSectionTitle(rows, 1))
assertEquals(listOf(ProviderListAction.ENABLE), rows[1].actions)
}
fun `test non popular providers appear in all providers alphabetically`() {
val rows = providerListRows(
ProviderSettingsDto(
providers = listOf(
provider("zeta", "Zeta"),
provider("alpha", "Alpha"),
provider("openai", "OpenAI"),
),
),
"",
)
assertEquals(listOf("openai", "alpha", "zeta"), rows.map { it.key })
assertEquals("All providers", providerListSectionTitle(rows, 1))
}
fun `test filtering by provider name updates rows and sections`() {
val content = content()
edt {
content.update(
ProviderSettingsDto(
providers = listOf(
provider("openai", "OpenAI"),
provider("anthropic", "Anthropic"),
provider("alpha", "Alpha Labs"),
),
),
)
search(content).text = "open"
val rows = rows(content)
assertEquals(listOf("openai"), rows.map { it.key })
assertEquals("Popular providers", providerListSectionTitle(rows, 0))
}
}
fun `test filtering does not match provider id`() {
val rows = providerListRows(
ProviderSettingsDto(
providers = listOf(provider("openai-compatible", "Local")),
),
"openai",
)
assertTrue(rows.isEmpty())
}
fun `test renderer hit test maps actions`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val bounds = Rectangle(0, 0, 320, 48)
val areas = ProviderListRenderer.actionBounds(list, bounds, row)
assertEquals(ProviderListAction.CONNECT, ProviderListRenderer.actionAt(list, bounds, center(areas.getValue(ProviderListAction.CONNECT)), row))
assertEquals(ProviderListAction.OAUTH, ProviderListRenderer.actionAt(list, bounds, center(areas.getValue(ProviderListAction.OAUTH)), row))
assertNull(ProviderListRenderer.actionAt(list, bounds, Point(4, 4), row))
}
}
fun `test renderer ignores disabled env disconnect action`() {
edt {
val row = ProviderListRow(provider("env", "Env", source = "env"), "All providers", listOf(ProviderListAction.DISCONNECT))
val list = JBList(listOf(row))
val bounds = Rectangle(0, 0, 320, 48)
val area = ProviderListRenderer.actionBounds(list, bounds, row).getValue(ProviderListAction.DISCONNECT)
assertNull(ProviderListRenderer.actionAt(list, bounds, center(area), row))
}
}
fun `test renderer exposes action labels`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
renderer.getListCellRendererComponent(list, row, 0, false, false)
assertEquals(listOf("OAuth", "Connect"), renderer.actionTexts())
}
}
fun `test renderer uses standard button foreground for actions`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
renderer.getListCellRendererComponent(list, row, 0, false, false)
val fg = UIManager.getColor("Button.foreground") ?: UIUtil.getLabelForeground()
val labels = components(renderer).filterIsInstance<JBLabel>().filter { it.text in listOf("OAuth", "Connect") }
assertEquals(listOf(fg, fg), labels.map { it.foreground })
}
}
fun `test renderer action labels paint with non button border`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val renderer = ProviderListRenderer(com.intellij.ui.CollectionListModel(listOf(row)))
renderer.getListCellRendererComponent(list, row, 0, false, false)
renderer.setSize(320, 64)
renderer.doLayout()
val image = BufferedImage(320, 64, BufferedImage.TYPE_INT_ARGB)
val g = image.createGraphics()
try {
renderer.paint(g)
} finally {
g.dispose()
}
}
}
fun `test provider reload clears loading overlay after state loads`() {
val rpc = installProvider(providerState(provider("openai", "OpenAI")))
val panel = edt { createUi() }
flushUntil { rpc.stateCalls.isNotEmpty() && edt { rows(panel).map { it.key } == listOf("openai") && !text(panel).contains("Loading providers") } }
edt {
assertEquals(listOf("openai"), rows(panel).map { it.key })
assertFalse(text(panel).contains("Loading providers"))
}
}
fun `test provider action failure returns error state`() = runBlocking {
val cs = CoroutineScope(SupervisorJob())
scope = cs
val rpc = FakeProviderRpcApi()
rpc.state = providerState(provider("openai", "OpenAI"))
rpc.disconnectError = IllegalStateException("Kilo backend is not ready")
val service = KiloProviderService(cs, rpc)
val result = withContext(kotlinx.coroutines.Dispatchers.Default) {
service.disconnect(ProviderDisconnectDto("/test", "openai"))
}
assertEquals("Kilo backend is not ready", result.error)
assertEquals(listOf("openai"), result.state.providers.map { it.id })
assertEquals(listOf("/test"), rpc.stateCalls)
}
fun `test stale reload result is ignored after newer reload`() {
val first = CompletableDeferred<ProviderSettingsDto>()
val second = CompletableDeferred<ProviderSettingsDto>()
val rpc = installProvider(ProviderSettingsDto())
rpc.states.add(first)
rpc.states.add(second)
val panel = edt { createUi() }
flushUntil { rpc.stateCalls.size == 1 }
edt { panel.reload() }
flushUntil { rpc.stateCalls.size == 2 }
second.complete(providerState(provider("new", "New")))
flushUntil { edt { rows(panel).map { it.key } == listOf("new") } }
first.complete(providerState(provider("old", "Old")))
flushUntil { first.isCompleted }
edt { assertEquals(listOf("new"), rows(panel).map { it.key }) }
}
fun `test dispose ignores pending reload completion`() {
val state = CompletableDeferred<ProviderSettingsDto>()
val rpc = installProvider(ProviderSettingsDto())
rpc.states.add(state)
val panel = edt { createUi() }
flushUntil { rpc.stateCalls.size == 1 }
edt {
panel.dispose()
ui = null
}
state.complete(providerState(provider("openai", "OpenAI")))
flushUntil { state.isCompleted }
edt { assertTrue(rows(panel).isEmpty()) }
}
private fun content() = edt { ProvidersContent({}, {}, {}, {}, {}, {}) }
private fun createUi(): ProvidersSettingsUi {
val cs = CoroutineScope(SupervisorJob())
scope = cs
val panel = ProvidersSettingsUi(cs, "/test")
ui = panel
return panel
}
private fun installProvider(state: ProviderSettingsDto): FakeProviderRpcApi {
val cs = CoroutineScope(SupervisorJob())
scope = cs
val rpc = FakeProviderRpcApi()
rpc.state = state
ApplicationManager.getApplication().replaceService(
KiloProviderService::class.java,
KiloProviderService(cs, rpc),
testRootDisposable,
)
return rpc
}
private fun providerState(vararg providers: ProviderSettingsProviderDto) = ProviderSettingsDto(providers = providers.toList())
private fun provider(id: String, name: String, source: String? = null) = ProviderSettingsProviderDto(
id = id,
name = name,
source = source,
models = mapOf("model" to ModelDto("model", "Model")),
)
private fun buttons(component: JComponent): List<String> = components(component)
.filterIsInstance<AbstractButton>()
.map { it.text }
private fun rows(component: JComponent): List<ProviderListRow> {
val model = list(component).model
return (0 until model.size).map { model.getElementAt(it) }
}
private fun components(component: JComponent): List<java.awt.Component> {
private fun list(component: JComponent) = components(component).filterIsInstance<JBList<ProviderListRow>>().single()
private fun search(component: JComponent) = components(component).filterIsInstance<SearchTextField>().single()
private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2)
private fun components(component: java.awt.Component): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
fun visit(c: java.awt.Component) {
out += c
@@ -81,4 +400,33 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
visit(component)
return out
}
private fun text(root: Container): String {
val out = mutableListOf<String>()
for (comp in components(root)) {
if (!comp.isVisible) continue
when (comp) {
is JButton -> comp.text?.let { out.add(it) }
is JBLabel -> comp.text?.let { out.add(it) }
}
}
return out.joinToString("\n")
}
private fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@Suppress("UNCHECKED_CAST")
return result as T
}
private fun flushUntil(done: () -> Boolean) = runBlocking {
repeat(20) {
delay(100)
edt { UIUtil.dispatchAllInvocationEvents() }
if (done()) return@runBlocking
}
edt { UIUtil.dispatchAllInvocationEvents() }
assertTrue(done())
}
}
@@ -0,0 +1,73 @@
package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloProviderRpcApi
import ai.kilocode.rpc.dto.CustomModelFetchDto
import ai.kilocode.rpc.dto.CustomModelFetchResultDto
import ai.kilocode.rpc.dto.CustomProviderSaveDto
import ai.kilocode.rpc.dto.ProviderActionResultDto
import ai.kilocode.rpc.dto.ProviderConnectDto
import ai.kilocode.rpc.dto.ProviderDisconnectDto
import ai.kilocode.rpc.dto.ProviderEnableDto
import ai.kilocode.rpc.dto.ProviderOAuthAuthorizeDto
import ai.kilocode.rpc.dto.ProviderOAuthCallbackDto
import ai.kilocode.rpc.dto.ProviderOAuthReadyDto
import ai.kilocode.rpc.dto.ProviderSettingsDto
import kotlinx.coroutines.CompletableDeferred
class FakeProviderRpcApi : KiloProviderRpcApi {
var state = ProviderSettingsDto()
val states = ArrayDeque<CompletableDeferred<ProviderSettingsDto>>()
val stateCalls = mutableListOf<String>()
val connects = mutableListOf<ProviderConnectDto>()
val disconnects = mutableListOf<ProviderDisconnectDto>()
val enables = mutableListOf<ProviderEnableDto>()
val custom = mutableListOf<CustomProviderSaveDto>()
var disconnectError: Exception? = null
override suspend fun state(directory: String): ProviderSettingsDto {
assertNotEdt("provider.state")
stateCalls.add(directory)
if (states.isNotEmpty()) return states.removeFirst().await()
return state
}
override suspend fun connect(input: ProviderConnectDto): ProviderActionResultDto {
assertNotEdt("provider.connect")
connects.add(input)
return ProviderActionResultDto(state)
}
override suspend fun authorize(input: ProviderOAuthAuthorizeDto): ProviderOAuthReadyDto {
assertNotEdt("provider.authorize")
return ProviderOAuthReadyDto()
}
override suspend fun callback(input: ProviderOAuthCallbackDto): ProviderActionResultDto {
assertNotEdt("provider.callback")
return ProviderActionResultDto(state)
}
override suspend fun disconnect(input: ProviderDisconnectDto): ProviderActionResultDto {
assertNotEdt("provider.disconnect")
disconnects.add(input)
disconnectError?.let { throw it }
return ProviderActionResultDto(state)
}
override suspend fun enable(input: ProviderEnableDto): ProviderActionResultDto {
assertNotEdt("provider.enable")
enables.add(input)
return ProviderActionResultDto(state)
}
override suspend fun saveCustom(input: CustomProviderSaveDto): ProviderActionResultDto {
assertNotEdt("provider.saveCustom")
custom.add(input)
return ProviderActionResultDto(state)
}
override suspend fun fetchCustomModels(input: CustomModelFetchDto): CustomModelFetchResultDto {
assertNotEdt("provider.fetchCustomModels")
return CustomModelFetchResultDto()
}
}