From b0183f984611df1b09145aa9716e948ee6bb4780 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 16 Jun 2026 15:49:13 -0400 Subject: [PATCH] fix(jetbrains): prefer headless provider oauth --- .../jetbrains-provider-headless-oauth.md | 5 + .../client/settings/auth/DeviceOAuthPanel.kt | 217 +++++++++++++++++ .../settings/{profile => auth}/QrCode.kt | 19 +- .../settings/profile/LoggedOutProfileUi.kt | 229 ++---------------- .../settings/providers/ProviderCatalog.kt | 10 + .../settings/providers/ProvidersSettingsUi.kt | 141 ++++++++--- .../providers/ProvidersSettingsUiTest.kt | 107 +++++++- 7 files changed, 452 insertions(+), 276 deletions(-) create mode 100644 .changeset/jetbrains-provider-headless-oauth.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt rename packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/{profile => auth}/QrCode.kt (56%) diff --git a/.changeset/jetbrains-provider-headless-oauth.md b/.changeset/jetbrains-provider-headless-oauth.md new file mode 100644 index 00000000000..dc87eced073 --- /dev/null +++ b/.changeset/jetbrains-provider-headless-oauth.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Prefer remote-safe provider OAuth methods in JetBrains and show device-code authorization details when available. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt new file mode 100644 index 00000000000..a4add09ded5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/DeviceOAuthPanel.kt @@ -0,0 +1,217 @@ +package ai.kilocode.client.settings.auth + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.awt.RelativePoint +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.AsyncProcessIcon +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.FlowLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.Point +import java.awt.datatransfer.StringSelection +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.JButton +import javax.swing.JPanel +import javax.swing.SwingConstants +import javax.swing.Timer + +internal data class DeviceOAuthInfo( + val url: String, + val code: String?, + val expiresIn: Int, + val started: Long, +) + +internal data class DeviceOAuthText( + val title: String, + val qrDescription: String, +) + +internal class DeviceOAuthPanel( + private val copy: DeviceOAuthText, + private val cancel: () -> Unit, + private val browse: (String) -> Unit, + private val prefix: String, +) : JPanel(GridBagLayout()) { + val urlField = JBTextField().apply { + isEditable = false + name = "$prefix.url" + columns = 30 + addFocusListener(object : FocusAdapter() { + override fun focusGained(e: FocusEvent) = selectAll() + }) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) = selectAll() + }) + } + + val qrLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + name = "$prefix.qr" + accessibleContext.accessibleName = KiloBundle.message("profile.login.qr") + accessibleContext.accessibleDescription = copy.qrDescription + } + + private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser")) + private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel")).also { it.addActionListener { cancel() } } + private val copyUrlBtn = HoverIcon().apply { + icon = AllIcons.Actions.Copy + toolTipText = KiloBundle.message("profile.login.copyUrl") + } + private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { + name = "$prefix.codePanel" + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val c = code ?: return + copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@DeviceOAuthPanel) + } + }) + } + private val codeLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + font = UiStyle.Fonts.large() + } + private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + } + private val waitIcon = AsyncProcessIcon("KiloOAuth") + private val waitLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + } + private var step2: SimpleColoredComponent? = null + private var code: String? = null + private var started = 0L + private var expires = 900 + private var last: String? = null + private val timer = Timer(1000) { syncTime() } + + init { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + codePanel.add(codeLabel, BorderLayout.CENTER) + codePanel.add(codeHint, BorderLayout.SOUTH) + build() + } + + private fun build() { + var row = 0 + add(JBLabel(copy.title).apply { + font = UiStyle.Fonts.heading() + horizontalAlignment = SwingConstants.CENTER + }, gbc(row++)) + add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")), gbc(row++, UiStyle.Gap.md())) + add(urlRow(), gbc(row++, UiStyle.Gap.sm())) + add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered()) + val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code")) + step2 = s2 + add(s2, gbc(row++, UiStyle.Gap.md())) + add(codePanel, gbc(row++, UiStyle.Gap.sm())) + add(JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(waitIcon) + add(waitLabel) + }, gbc(row++, UiStyle.Gap.xl())) + add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered()) + } + + private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply { + append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) + append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } + + private fun urlRow(): JPanel { + val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)) + row.add(urlField, BorderLayout.CENTER) + row.add(JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(copyUrlBtn) + add(openBtn) + }, BorderLayout.EAST) + return row + } + + @RequiresEdt + fun update(info: DeviceOAuthInfo) { + code = info.code + urlField.text = info.url + urlField.toolTipText = info.url + if (info.url != last) { + last = info.url + openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) } + openBtn.addActionListener { browse(info.url) } + copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) } + copyUrlBtn.addActionListener { copyToClipboard(info.url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn) } + qrLabel.icon = try { + QrCode.icon(info.url, JBUI.scale(160)) + } catch (_: Exception) { + null + } + } + codePanel.isVisible = info.code != null + step2?.isVisible = info.code != null + if (info.code != null) codeLabel.text = spaced(info.code) + started = info.started + expires = info.expiresIn + syncTime() + waitIcon.resume() + timer.restart() + } + + @RequiresEdt + fun dispose() { + timer.stop() + waitIcon.suspend() + last = null + } + + @RequiresEdt + private fun syncTime() { + val elapsed = ((System.currentTimeMillis() - started) / 1000).toInt() + val remain = (expires - elapsed).coerceAtLeast(0) + val min = remain / 60 + val sec = remain % 60 + waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}") + } + + private fun gbc(y: Int, top: Int = 0) = GridBagConstraints().apply { + gridx = 0 + gridy = y + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + insets = JBUI.insetsTop(top) + } + + private fun GridBagConstraints.centered(): GridBagConstraints = apply { + fill = GridBagConstraints.NONE + anchor = GridBagConstraints.CENTER + } + + private fun spaced(code: String): String = code.map { it.toString() }.joinToString(" ") +} + +internal fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) { + CopyPasteManager.getInstance().setContents(StringSelection(text)) + if (anchor is javax.swing.JComponent) { + val point = RelativePoint(anchor, Point(anchor.width / 2, 0)) + JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder(msg, null, null, null) + .createBalloon() + .show(point, Balloon.Position.above) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/QrCode.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/QrCode.kt similarity index 56% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/QrCode.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/QrCode.kt index 910c2088de4..d5bfee40e00 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/QrCode.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/auth/QrCode.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.settings.profile +package ai.kilocode.client.settings.auth import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType @@ -8,17 +8,6 @@ import java.awt.image.BufferedImage import javax.swing.ImageIcon internal object QrCode { - - /** - * Generate a QR code image for [text]. - * - * Uses black modules on a white background regardless of IDE theme — this is - * intentional for scanning reliability (QR scanners expect high contrast B/W). - * - * @param text URL or text to encode; must not be blank. - * @param size pixel dimension for both width and height. - * @throws IllegalArgumentException if [text] is blank. - */ fun image(text: String, size: Int = 160): BufferedImage { require(text.isNotBlank()) { "QR text must not be blank" } val hints = mapOf(EncodeHintType.MARGIN to 2) @@ -32,11 +21,5 @@ internal object QrCode { return img } - /** - * Convenience wrapper that returns the QR code as an [ImageIcon]. - * - * @param text URL or text to encode; must not be blank. - * @param size pixel dimension for both width and height. - */ fun icon(text: String, size: Int = 160): ImageIcon = ImageIcon(image(text, size)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt index 03326e588a7..ceb42001a54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -1,20 +1,13 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.ui.HoverIcon -import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.settings.auth.DeviceOAuthInfo +import ai.kilocode.client.settings.auth.DeviceOAuthPanel +import ai.kilocode.client.settings.auth.DeviceOAuthText import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.KiloAppStatusDto -import com.intellij.icons.AllIcons -import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.util.IconLoader -import com.intellij.openapi.ui.popup.Balloon -import com.intellij.openapi.ui.popup.JBPopupFactory -import com.intellij.ui.SimpleColoredComponent -import com.intellij.ui.SimpleTextAttributes -import com.intellij.ui.awt.RelativePoint import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBTextField import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.AsyncProcessIcon import com.intellij.util.ui.JBUI @@ -23,17 +16,10 @@ import java.awt.CardLayout import java.awt.FlowLayout import java.awt.GridBagConstraints import java.awt.GridBagLayout -import java.awt.Point -import java.awt.datatransfer.StringSelection -import java.awt.event.FocusAdapter -import java.awt.event.FocusEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingConstants -import javax.swing.Timer internal enum class OutMode { CONNECTING, APP_ERROR, INITIATING, AUTH, LOGIN_ERROR, EMPTY } @@ -66,65 +52,18 @@ internal class LoggedOutProfileUi( private val authRetryBtn = JButton(KiloBundle.message("profile.login.tryAgain")) .also { it.addActionListener { login() } } - private val cancelBtn = JButton(KiloBundle.message("profile.login.cancel")) - .also { it.addActionListener { cancel() } } - - private val openBtn = JButton(KiloBundle.message("profile.login.openBrowser")) - - private val copyUrlBtn = HoverIcon().apply { - icon = AllIcons.Actions.Copy - toolTipText = KiloBundle.message("profile.login.copyUrl") - } - - // -- retained auth card components -- - val urlField = JBTextField().apply { - isEditable = false - name = "kilo.login.url" - columns = 30 - // Select all on focus so clicking the field selects the whole URL - addFocusListener(object : FocusAdapter() { - override fun focusGained(e: FocusEvent) = selectAll() - }) - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) = selectAll() - }) - } - - val qrLabel = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - name = "kilo.login.qr" - accessibleContext.accessibleName = KiloBundle.message("profile.login.qr") - accessibleContext.accessibleDescription = KiloBundle.message("profile.login.qr.description") - } - - private val codePanel = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.md()).apply { - name = "kilo.login.codePanel" - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - val c = rawCode ?: return - copyToClipboard(c, KiloBundle.message("profile.login.codeCopied"), this@LoggedOutProfileUi) - } - }) - } - - private val codeLabel = JBLabel().apply { - horizontalAlignment = SwingConstants.CENTER - font = UiStyle.Fonts.large() - } - - private val codeHint = JBLabel(KiloBundle.message("profile.login.clickToCopy")).apply { - foreground = UiStyle.Colors.weak() - horizontalAlignment = SwingConstants.CENTER - } + private val auth = DeviceOAuthPanel( + DeviceOAuthText( + title = KiloBundle.message("profile.login.title"), + qrDescription = KiloBundle.message("profile.login.qr.description"), + ), + cancel = cancel, + browse = browse, + prefix = "kilo.login", + ) private val initiatingIcon = AsyncProcessIcon("KiloInitiating").also { it.suspend() } - private val waitIcon = AsyncProcessIcon("KiloLogin") - - private val waitLabel = JBLabel().apply { - foreground = UiStyle.Colors.weak() - } - private val logoLabel = JBLabel(IconLoader.getIcon("/icons/kilo-profile.svg", LoggedOutProfileUi::class.java)).apply { name = "kilo.profile.logo.loggedOut" horizontalAlignment = SwingConstants.CENTER @@ -136,28 +75,12 @@ internal class LoggedOutProfileUi( horizontalAlignment = SwingConstants.CENTER } - // -- step 2 label reference for visibility toggling -- - private var step2Label: SimpleColoredComponent? = null - - // -- countdown state -- - private var rawCode: String? = null - private var pendingStarted = 0L - private var pendingExpires = 900 - - // -- cached URL for listener/QR deduplication -- - private var lastPendingUrl: String? = null - - private val timer = Timer(1000) { syncTime() } - init { - codePanel.add(codeLabel, BorderLayout.CENTER) - codePanel.add(codeHint, BorderLayout.SOUTH) - cards.add(connectingCard(), OutMode.CONNECTING.name) cards.add(appErrorCard(), OutMode.APP_ERROR.name) cards.add(emptyCard(), OutMode.EMPTY.name) cards.add(initiatingCard(), OutMode.INITIATING.name) - cards.add(authCard(), OutMode.AUTH.name) + cards.add(auth, OutMode.AUTH.name) cards.add(loginErrorCard(), OutMode.LOGIN_ERROR.name) add(cards, BorderLayout.NORTH) } @@ -208,57 +131,6 @@ internal class LoggedOutProfileUi( return p } - private fun authCard(): JPanel { - val p = padded() - var row = 0 - - p.add(JBLabel(KiloBundle.message("profile.login.title")).apply { - font = UiStyle.Fonts.heading() - horizontalAlignment = SwingConstants.CENTER - }, gbc(row++)) - - p.add(stepLabel(KiloBundle.message("profile.login.step.one"), KiloBundle.message("profile.login.step.url")), - gbc(row++, UiStyle.Gap.md())) - - p.add(urlRow(), gbc(row++, UiStyle.Gap.sm())) - - p.add(qrLabel, gbc(row++, UiStyle.Gap.md()).centered()) - - val s2 = stepLabel(KiloBundle.message("profile.login.step.two"), KiloBundle.message("profile.login.step.code")) - step2Label = s2 - p.add(s2, gbc(row++, UiStyle.Gap.md())) - - p.add(codePanel, gbc(row++, UiStyle.Gap.sm())) - - val waitRow = JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { - isOpaque = false - add(waitIcon) - add(waitLabel) - } - p.add(waitRow, gbc(row++, UiStyle.Gap.xl())) - - p.add(cancelBtn, gbc(row, UiStyle.Gap.sm()).centered()) - - return p - } - - private fun stepLabel(step: String, text: String) = SimpleColoredComponent().apply { - append(step, SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) - append(" $text", SimpleTextAttributes.GRAYED_ATTRIBUTES) - } - - private fun urlRow(): JPanel { - val row = JPanel(BorderLayout(UiStyle.Gap.xs(), 0)) - row.add(urlField, BorderLayout.CENTER) - val btns = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.sm(), 0)).apply { - isOpaque = false - add(copyUrlBtn) - add(openBtn) - } - row.add(btns, BorderLayout.EAST) - return row - } - private fun loginErrorCard(): JPanel { val p = padded() p.add(errLabel, gbc(0)) @@ -274,46 +146,7 @@ internal class LoggedOutProfileUi( if (target == OutMode.AUTH && login is LoginState.Pending) { val auth = login.auth - val url = auth.verificationUrl - val code = auth.code - - rawCode = code - urlField.text = url - urlField.toolTipText = url - - // Wire listeners and generate QR only when URL changes (avoids re-wiring on every re-sync) - if (url != lastPendingUrl) { - lastPendingUrl = url - - openBtn.actionListeners.toList().forEach { openBtn.removeActionListener(it) } - openBtn.addActionListener { browse(url) } - copyUrlBtn.actionListeners.toList().forEach { copyUrlBtn.removeActionListener(it) } - copyUrlBtn.addActionListener { - copyToClipboard(url, KiloBundle.message("profile.login.urlCopied"), copyUrlBtn) - } - - // QR code — expensive; only regenerate when URL changes - try { - qrLabel.icon = QrCode.icon(url, JBUI.scale(160)) - } catch (_: Exception) { - qrLabel.icon = null - } - } - - // Code display - codePanel.isVisible = code != null - step2Label?.isVisible = code != null - if (code != null) { - codeLabel.text = spacedCode(code) - } - - // Countdown: only reset when entering auth for the first time for this pending - if (mode != OutMode.AUTH) { - pendingStarted = login.started - pendingExpires = auth.expiresIn - syncTime() - timer.restart() - } + this.auth.update(DeviceOAuthInfo(auth.verificationUrl, auth.code, auth.expiresIn, login.started)) } if (target == OutMode.LOGIN_ERROR && login is LoginState.Error) { @@ -322,16 +155,11 @@ internal class LoggedOutProfileUi( if (mode != target) { if (mode == OutMode.AUTH) { - timer.stop() - waitIcon.suspend() - lastPendingUrl = null + auth.dispose() } if (mode == OutMode.INITIATING) initiatingIcon.suspend() cardLayout.show(cards, target.name) mode = target - if (target == OutMode.AUTH) { - waitIcon.resume() - } if (target == OutMode.INITIATING) initiatingIcon.resume() revalidate() repaint() @@ -344,10 +172,8 @@ internal class LoggedOutProfileUi( /** Stop the timer and suspend all animated icons. Safe to call multiple times. */ @RequiresEdt fun dispose() { - timer.stop() - waitIcon.suspend() initiatingIcon.suspend() - lastPendingUrl = null + auth.dispose() } private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { @@ -359,15 +185,6 @@ internal class LoggedOutProfileUi( else -> OutMode.EMPTY } - @RequiresEdt - private fun syncTime() { - val elapsed = ((System.currentTimeMillis() - pendingStarted) / 1000).toInt() - val remain = (pendingExpires - elapsed).coerceAtLeast(0) - val min = remain / 60 - val sec = remain % 60 - waitLabel.text = KiloBundle.message("profile.login.waitingTimed", "$min:${sec.toString().padStart(2, '0')}") - } - // ---- helpers ---- private fun padded() = JPanel(GridBagLayout()).apply { @@ -386,18 +203,4 @@ internal class LoggedOutProfileUi( fill = GridBagConstraints.NONE anchor = GridBagConstraints.CENTER } - - private fun spacedCode(code: String): String = code.map { it.toString() }.joinToString(" ") -} - -/** Copy [text] to the platform clipboard and show a brief confirmation balloon anchored to [anchor]. */ -private fun copyToClipboard(text: String, msg: String, anchor: java.awt.Component) { - CopyPasteManager.getInstance().setContents(StringSelection(text)) - if (anchor is javax.swing.JComponent) { - val point = RelativePoint(anchor, Point(anchor.width / 2, 0)) - JBPopupFactory.getInstance() - .createHtmlTextBalloonBuilder(msg, null, null, null) - .createBalloon() - .show(point, Balloon.Position.above) - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt index d6710c407af..c4e260cd9ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProviderCatalog.kt @@ -76,6 +76,16 @@ internal fun providerMethods(provider: ProviderSettingsProviderDto, state: Provi return listOf(ProviderAuthMethodDto("api", "API key")) } +internal fun providerOAuthMethodIndex(methods: List): String? { + val indexed = methods.withIndex().filter { it.value.type == "oauth" } + if (indexed.isEmpty()) return null + val remote = indexed.firstOrNull { entry -> + val label = entry.value.label.lowercase() + listOf("headless", "remote", "device", "vps").any { label.contains(it) } + } + return (remote ?: indexed.first()).index.toString() +} + internal fun hiddenProvider(provider: ProviderSettingsProviderDto) = provider.id == "openai-compatible" internal fun configured(provider: ProviderSettingsProviderDto, state: ProviderSettingsDto, ids: Set) = diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt index 48609b3d7fd..3a88760f321 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUi.kt @@ -4,6 +4,9 @@ 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.SettingsPanel +import ai.kilocode.client.settings.auth.DeviceOAuthInfo +import ai.kilocode.client.settings.auth.DeviceOAuthPanel +import ai.kilocode.client.settings.auth.DeviceOAuthText import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.KiloLog @@ -69,6 +72,10 @@ import javax.swing.Timer private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() +private val OAUTH_CODE_RE = Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE) + +private fun oauthCode(text: String?): String? = text?.let { OAUTH_CODE_RE.find(it)?.groupValues?.getOrNull(1) } + internal class ProvidersSettingsUi( private val cs: CoroutineScope, private val directory: String, @@ -90,15 +97,19 @@ internal class ProvidersSettingsUi( { !busy }, ) { reload() } private val view = ProvidersContent(::connect, ::oauth, ::disconnect, ::enable) + private val search = SearchTextField(false).apply { + textEditor.emptyText.text = KiloBundle.message("settings.providers.search") + } private var state = ProviderSettingsDto() private var job: Job? = null private var request = 0 private var disposed = false private var busy = false private var timer: Timer? = null + private var oauth: DeviceOAuthPanel? = null init { - content.add(toolbar(), BorderLayout.NORTH) + content.add(header(), BorderLayout.NORTH) setContent(view) reload() } @@ -139,8 +150,7 @@ internal class ProvidersSettingsUi( @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() + val method = providerOAuthMethodIndex(state.auth[provider.id].orEmpty()) ?: return if (!launch("authorize provider=${provider.id}") { id -> val ready = service().authorize(ProviderOAuthAuthorizeDto(directory, provider.id, method)) val code = withContext(edt) { @@ -153,11 +163,28 @@ internal class ProvidersSettingsUi( return@withContext null } input - } else null + } else { + val url = ready.url + if (ready.method == "auto" && url != null) { + showOAuthDevice( + id, + provider, + DeviceOAuthInfo( + url = url, + code = oauthCode(ready.instructions), + expiresIn = (KiloProviderService.OAUTH_RPC_TIMEOUT_MS / 1000).toInt(), + started = System.currentTimeMillis(), + ), + ) + } + null + } } val current = withContext(edt) { active(id) } if (!current) return@launch - withContext(edt) { syncOAuthWaiting(id) } + withContext(edt) { + if (oauth == null) syncOAuthWaiting(id) + } val result = service().callback(ProviderOAuthCallbackDto(directory, provider.id, method, code)) apply(id, result.state, result.error) }) return @@ -208,6 +235,32 @@ internal class ProvidersSettingsUi( return toolbar.component } + private fun header(): JComponent { + search.textEditor.registerKeyboardAction( + { view.primary() }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.registerKeyboardAction( + { view.move(-1) }, + KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.registerKeyboardAction( + { view.move(1) }, + KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), + JComponent.WHEN_FOCUSED, + ) + search.textEditor.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + view.filter(search.text) + } + }) + return Stack.vertical(UiStyle.Gap.sm()) + .next(toolbar()) + .next(search) + } + @RequiresEdt private fun launch(name: String, block: suspend (Int) -> Unit): Boolean { checkEdt() @@ -225,6 +278,7 @@ internal class ProvidersSettingsUi( withContext(edt) { if (!active(id)) return@withContext setBusy(false) + clearOAuthDevice() clearProgress() } } catch (e: CancellationException) { @@ -235,6 +289,7 @@ internal class ProvidersSettingsUi( withContext(edt) { if (!active(id)) return@withContext setBusy(false) + clearOAuthDevice() showError("${e::class.simpleName}: ${e.message}") } } @@ -248,6 +303,7 @@ internal class ProvidersSettingsUi( LOG.info("provider settings ui apply: start providers=${next.providers.size} errors=${next.errors.size} message=${error != null}") state = next setBusy(false) + clearOAuthDevice() view.update(next) val text = error ?: next.errors.joinToString("; ") { it.detail ?: it.resource }.takeIf { it.isNotBlank() } if (text != null) showError(text) else clearProgress() @@ -286,10 +342,39 @@ internal class ProvidersSettingsUi( job?.cancel() job = null stopTimer() + clearOAuthDevice() setBusy(false) clearProgress() } + @RequiresEdt + private fun showOAuthDevice(id: Int, provider: ProviderSettingsProviderDto, info: DeviceOAuthInfo) { + checkEdt() + if (!active(id)) return + clearProgress() + val panel = DeviceOAuthPanel( + DeviceOAuthText( + title = KiloBundle.message("settings.providers.oauth.starting", provider.name), + qrDescription = KiloBundle.message("profile.login.qr.description"), + ), + cancel = { cancelOAuth(id) }, + browse = { BrowserUtil.browse(it) }, + prefix = "kilo.provider.oauth", + ) + oauth?.dispose() + oauth = panel + panel.update(info) + setModalContent(panel) + } + + @RequiresEdt + private fun clearOAuthDevice() { + checkEdt() + oauth?.dispose() + oauth = null + setModalContent(null) + } + @RequiresEdt private fun stopTimer() { checkEdt() @@ -303,6 +388,8 @@ internal class ProvidersSettingsUi( if (busy == next) return busy = next if (!next) stopTimer() + search.isEnabled = !next + search.textEditor.isEnabled = !next view.setBusy(next) } @@ -339,10 +426,8 @@ internal class ProvidersContent( 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() + private var filter = "" private var busy = false init { @@ -352,26 +437,6 @@ internal class ProvidersContent( 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 @@ -385,9 +450,7 @@ internal class ProvidersContent( } }) ScrollingUtil.installActions(list) - next(search) - .gap(UiStyle.Gap.sm()) - .next(list) + next(list) } @RequiresEdt @@ -405,16 +468,22 @@ internal class ProvidersContent( checkEdt() if (busy == next) return busy = next - search.isEnabled = !next - search.textEditor.isEnabled = !next list.isEnabled = !next sync() } + @RequiresEdt + fun filter(text: String) { + checkEdt() + if (filter == text) return + filter = text + sync() + } + @RequiresEdt private fun sync(prefer: String? = list.selectedValue?.key, at: Int? = null) { checkEdt() - val rows = providerListRows(state, search.text, disabledRows = busy) + val rows = providerListRows(state, filter, disabledRows = busy) model.replaceAll(rows) val idx = at?.let { providerListIndex(rows, it) }?.takeIf { it >= 0 } ?: providerListIndex(rows, prefer).takeIf { it >= 0 } @@ -432,7 +501,7 @@ internal class ProvidersContent( } @RequiresEdt - private fun move(step: Int) { + fun move(step: Int) { checkEdt() val size = model.size if (size <= 0) return @@ -441,7 +510,7 @@ internal class ProvidersContent( } @RequiresEdt - private fun primary() { + fun primary() { checkEdt() val row = list.selectedValue ?: return val action = ProviderListRenderer.visibleActions(row, true).firstOrNull() ?: return diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 16a8efca5bd..003d7057861 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -14,6 +14,7 @@ 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.SimpleColoredComponent import com.intellij.ui.SearchTextField import com.intellij.ui.components.JBList import com.intellij.ui.components.JBLabel @@ -40,6 +41,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JScrollPane import javax.swing.KeyStroke +import javax.swing.JTextField import javax.swing.UIManager @Suppress("UNCHECKED_CAST") @@ -96,29 +98,31 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { edt { assertEquals(listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), rows(content).single().actions) } } - fun `test content uses toolbar search and direct list`() { + fun `test content uses direct list without scroll or search`() { val content = content() edt { - assertEquals(1, components(content).filterIsInstance().size) assertEquals(1, components(content).filterIsInstance>().size) + assertTrue(components(content).filterIsInstance().isEmpty()) assertTrue(components(content).filterIsInstance().isEmpty()) assertTrue(components(content).filterIsInstance().none { it.text == "Refresh" }) } } - fun `test toolbar is outside scrollable provider content`() { + fun `test toolbar and search are outside scrollable provider content`() { installProvider(ProviderSettingsDto()) val panel = edt { createUi() } edt { val layout = panel.content.layout as BorderLayout - val toolbar = layout.getLayoutComponent(BorderLayout.NORTH) + val header = layout.getLayoutComponent(BorderLayout.NORTH) val scroll = layout.getLayoutComponent(BorderLayout.CENTER) - assertNotNull(toolbar) + assertNotNull(header) assertTrue(scroll is JScrollPane) - assertFalse(components(content(panel)).contains(toolbar)) - assertEquals(1, components(content(panel)).filterIsInstance().size) + assertEquals(1, components(header).filterIsInstance().size) + assertTrue(components(scroll).filterIsInstance().isEmpty()) + assertFalse(components(content(panel)).contains(header)) + assertTrue(components(content(panel)).filterIsInstance().isEmpty()) assertEquals(1, components(content(panel)).filterIsInstance>().size) } } @@ -263,7 +267,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { ), ) - search(content).text = "open" + content.filter("open") val rows = rows(content) assertEquals(listOf("openai"), rows.map { it.key }) @@ -552,6 +556,89 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { } } + fun `test provider oauth prefers headless method original index`() { + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("openai", "OpenAI")), + auth = mapOf( + "openai" to listOf( + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus"), + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus (headless)"), + ), + ), + ), + ) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("openai") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 } + + assertEquals("1", rpc.authorizes.single().method) + } + + fun `test provider oauth falls back to first oauth method original index`() { + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("github-copilot", "GitHub Copilot")), + auth = mapOf( + "github-copilot" to listOf( + ProviderAuthMethodDto("oauth", "OAuth"), + ), + ), + ), + ) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("github-copilot") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.authorizes.size == 1 } + + assertEquals("0", rpc.authorizes.single().method) + } + + fun `test provider oauth auto response shows device auth panel`() { + val callback = CompletableDeferred() + val rpc = installProvider( + ProviderSettingsDto( + providers = listOf(provider("openai", "OpenAI")), + auth = mapOf( + "openai" to listOf( + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus"), + ProviderAuthMethodDto("oauth", "ChatGPT Pro/Plus (headless)"), + ), + ), + ), + ) + rpc.ready = ProviderOAuthReadyDto( + method = "auto", + url = "https://auth.openai.com/device", + instructions = "Enter code: ABCD-EFGH", + ) + rpc.callbacksReady.add(callback) + val panel = edt { createUi() } + + flushUntil { rpc.stateCalls.size == 1 && edt { rows(panel).map { it.key } == listOf("openai") } } + edt { triggerPrimary(panel) } + flushUntil { rpc.callbacks.size == 1 && edt { text(panel).contains("Waiting for authorization... (1:30)") } } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Starting OAuth for OpenAI")) + assertTrue(t, t.contains("Open this URL")) + assertTrue(t, t.contains("A B C D - E F G H")) + assertTrue(t, t.contains("Open Browser")) + assertTrue(t, t.contains("Cancel")) + assertEquals("https://auth.openai.com/device", fieldsByName(panel, "kilo.provider.oauth.url").single().text) + val qr = components(panel).filterIsInstance().single { it.name == "kilo.provider.oauth.qr" } + assertNotNull(qr.icon) + } + + edt { components(panel).filterIsInstance().single { it.text == "Cancel" && it.isVisible }.doClick() } + flushUntil { edt { rpc.callbacks.size == 1 && rows(panel).single().disabled.not() } } + callback.complete(ai.kilocode.rpc.dto.ProviderActionResultDto(providerState(provider("stale", "Stale")))) + } + fun `test provider oauth cancel before authorize completion skips callback`() { val ready = CompletableDeferred() val rpc = installProvider( @@ -710,7 +797,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { private fun list(component: JComponent) = components(component).filterIsInstance>().single() - private fun search(component: JComponent) = components(component).filterIsInstance().single() + private fun fieldsByName(root: Container, name: String): List = components(root).filterIsInstance().filter { it.name == name } private fun center(rect: Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) @@ -737,6 +824,8 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { when (comp) { is JButton -> comp.text?.let { out.add(it) } is JBLabel -> comp.text?.let { out.add(it) } + is JTextField -> comp.text?.let { out.add(it) } + is SimpleColoredComponent -> comp.toString().takeIf { it.isNotBlank() }?.let { out.add(it) } } } return out.joinToString("\n")