fix(jetbrains): prefer headless provider oauth

This commit is contained in:
kirillk
2026-06-16 15:49:13 -04:00
parent e13ee2fe23
commit b0183f9846
7 changed files with 452 additions and 276 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Prefer remote-safe provider OAuth methods in JetBrains and show device-code authorization details when available.
@@ -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)
}
}
@@ -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))
}
@@ -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)
}
}
@@ -76,6 +76,16 @@ internal fun providerMethods(provider: ProviderSettingsProviderDto, state: Provi
return listOf(ProviderAuthMethodDto("api", "API key"))
}
internal fun providerOAuthMethodIndex(methods: List<ProviderAuthMethodDto>): 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<String>) =
@@ -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<KiloProviderService>().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<KiloProviderService>().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
@@ -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<SearchTextField>().size)
assertEquals(1, components(content).filterIsInstance<JBList<ProviderListRow>>().size)
assertTrue(components(content).filterIsInstance<SearchTextField>().isEmpty())
assertTrue(components(content).filterIsInstance<JScrollPane>().isEmpty())
assertTrue(components(content).filterIsInstance<JButton>().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<SearchTextField>().size)
assertEquals(1, components(header).filterIsInstance<SearchTextField>().size)
assertTrue(components(scroll).filterIsInstance<SearchTextField>().isEmpty())
assertFalse(components(content(panel)).contains(header))
assertTrue(components(content(panel)).filterIsInstance<SearchTextField>().isEmpty())
assertEquals(1, components(content(panel)).filterIsInstance<JBList<ProviderListRow>>().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<ai.kilocode.rpc.dto.ProviderActionResultDto>()
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<JBLabel>().single { it.name == "kilo.provider.oauth.qr" }
assertNotNull(qr.icon)
}
edt { components(panel).filterIsInstance<JButton>().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<ProviderOAuthReadyDto>()
val rpc = installProvider(
@@ -710,7 +797,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
private fun list(component: JComponent) = components(component).filterIsInstance<JBList<ProviderListRow>>().single()
private fun search(component: JComponent) = components(component).filterIsInstance<SearchTextField>().single()
private fun fieldsByName(root: Container, name: String): List<JTextField> = components(root).filterIsInstance<JTextField>().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")