diff --git a/.changeset/jetbrains-profile-balance.md b/.changeset/jetbrains-profile-balance.md new file mode 100644 index 00000000000..6cb17979a7a --- /dev/null +++ b/.changeset/jetbrains-profile-balance.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show Kilo Pass usage, bonus credits, renewal date, and top-up actions in the JetBrains user profile. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 27b6ce1f4a8..0693d9c79f2 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -30,6 +30,7 @@ import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileKiloPassDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import ai.kilocode.rpc.dto.ProfileStatusDto import ai.kilocode.rpc.dto.TelemetryCaptureDto @@ -148,6 +149,14 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto( ProfileOrganizationDto(id = org.id, name = org.name, role = org.role) }, balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) }, + kiloPass = p.kiloPass?.let { + ProfileKiloPassDto( + currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd, + currentPeriodUsageUsd = it.currentPeriodUsageUsd, + currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd, + nextBillingAt = it.nextBillingAt, + ) + }, currentOrgId = p.currentOrgId, ) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt index 57fda81dec0..3c324658ad2 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ApiModelSerializationTest.kt @@ -110,6 +110,7 @@ class ApiModelSerializationTest { val src = """{ "profile": {"email": "user@test.com", "name": "User"}, "balance": {"balance": 42.5}, + "kiloPass": null, "currentOrgId": "org-1" }""" val obj = json.decodeFromString(src) @@ -125,6 +126,7 @@ class ApiModelSerializationTest { val src = """{ "profile": {"email": "user@test.com"}, "balance": null, + "kiloPass": null, "currentOrgId": null }""" val obj = json.decodeFromString(src) @@ -144,6 +146,7 @@ class ApiModelSerializationTest { ] }, "balance": null, + "kiloPass": null, "currentOrgId": "org-1" }""" val obj = json.decodeFromString(src) @@ -153,6 +156,27 @@ class ApiModelSerializationTest { assertEquals("admin", obj.profile.organizations!![0].role) } + @Test + fun `KiloProfile200Response with kilo pass`() { + val src = """{ + "profile": {"email": "user@test.com"}, + "balance": {"balance": 267.59}, + "kiloPass": { + "currentPeriodBaseCreditsUsd": 199, + "currentPeriodUsageUsd": 73.27, + "currentPeriodBonusCreditsUsd": 99.5, + "nextBillingAt": "2026-07-01T00:00:00.000Z" + }, + "currentOrgId": null + }""" + val obj = json.decodeFromString(src) + assertNotNull(obj.kiloPass) + assertEquals(199.0, obj.kiloPass!!.currentPeriodBaseCreditsUsd) + assertEquals(73.27, obj.kiloPass!!.currentPeriodUsageUsd) + assertEquals(99.5, obj.kiloPass!!.currentPeriodBonusCreditsUsd) + assertEquals("2026-07-01T00:00:00.000Z", obj.kiloPass!!.nextBillingAt) + } + @Test fun `Config roundtrip preserves model field`() { val original = Config(model = "gpt-4o", username = "test") diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt index bf5e418e694..09c3f8cb3f1 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/normalization/OpenApiSpecNormalizer.kt @@ -106,7 +106,7 @@ internal object OpenApiSpecNormalizer { /** * Fix the `/kilo/profile` GET 200 response schema: Effect's OpenAPI generator - * emits `balance` and `currentOrgId` as non-nullable required fields even + * emits `balance`, `kiloPass`, and `currentOrgId` as non-nullable required fields even * though the server schema is `Schema.NullOr(...)`. Wrap each non-nullable * property in `anyOf: [, {"type": "null"}]` so the generated * Kotlin model uses a nullable type. Already-nullable properties (those that @@ -124,7 +124,7 @@ internal object OpenApiSpecNormalizer { as? JsonObject ?: return root val props = schema["properties"] as? JsonObject ?: return root - val nullable = setOf("balance", "currentOrgId") + val nullable = setOf("balance", "kiloPass", "currentOrgId") val fixed = JsonObject(props.mapValues { (key, value) -> if (key !in nullable) return@mapValues value val obj = value as? JsonObject ?: return@mapValues value diff --git a/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt b/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt index 5f999c74625..492ea3c0a61 100644 --- a/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt +++ b/packages/kilo-jetbrains/build-tasks/src/test/kotlin/normalization/OpenApiSpecNormalizerTest.kt @@ -105,7 +105,7 @@ class OpenApiSpecNormalizerTest { } @Test - fun `makes balance and currentOrgId nullable in kilo profile response`() { + fun `makes nullable profile fields nullable in kilo profile response`() { val raw = """ { "paths": { @@ -121,9 +121,10 @@ class OpenApiSpecNormalizerTest { "properties": { "profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false }, "balance": { "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false }, + "kiloPass": { "type": "object", "properties": { "currentPeriodBaseCreditsUsd": { "type": "number" } }, "required": ["currentPeriodBaseCreditsUsd"], "additionalProperties": false }, "currentOrgId": { "type": "string" } }, - "required": ["profile", "balance", "currentOrgId"], + "required": ["profile", "balance", "kiloPass", "currentOrgId"], "additionalProperties": false } } @@ -148,6 +149,14 @@ class OpenApiSpecNormalizerTest { assert("null" in balanceTypes) { "balance anyOf should include null but got $balanceTypes" } assert(balanceAnyOf.any { it is JsonObject && "properties" in it }) { "balance anyOf should include the object schema" } + // kiloPass must be anyOf [object, null] + val pass = obj(props["kiloPass"]) + val passAnyOf = arr(pass["anyOf"]) + assertEquals(2, passAnyOf.size, "kiloPass should have anyOf with 2 entries") + val passTypes = passAnyOf.map { (it as? JsonObject)?.get("type").let { t -> (t as? JsonPrimitive)?.content } } + assert("null" in passTypes) { "kiloPass anyOf should include null but got $passTypes" } + assert(passAnyOf.any { it is JsonObject && "properties" in it }) { "kiloPass anyOf should include the object schema" } + // currentOrgId must be anyOf [string, null] val orgId = obj(props["currentOrgId"]) val orgIdAnyOf = arr(orgId["anyOf"]) @@ -164,7 +173,7 @@ class OpenApiSpecNormalizerTest { @Test fun `leaves already-nullable fields unchanged in kilo profile response`() { - // If balance already has anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap it. + // If nullable fields already have anyOf (i.e. the spec was generated correctly), normalizer must not double-wrap them. val raw = """ { "paths": { @@ -180,9 +189,10 @@ class OpenApiSpecNormalizerTest { "properties": { "profile": { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"], "additionalProperties": false }, "balance": { "anyOf": [{ "type": "object", "properties": { "balance": { "type": "number" } }, "required": ["balance"], "additionalProperties": false }, { "type": "null" }] }, + "kiloPass": { "anyOf": [{ "type": "object", "properties": { "currentPeriodBaseCreditsUsd": { "type": "number" } }, "required": ["currentPeriodBaseCreditsUsd"], "additionalProperties": false }, { "type": "null" }] }, "currentOrgId": { "anyOf": [{ "type": "string" }, { "type": "null" }] } }, - "required": ["profile", "balance", "currentOrgId"], + "required": ["profile", "balance", "kiloPass", "currentOrgId"], "additionalProperties": false } } @@ -203,6 +213,9 @@ class OpenApiSpecNormalizerTest { val balance = obj(props["balance"]) val balanceAnyOf = arr(balance["anyOf"]) assertEquals(2, balanceAnyOf.size, "balance should still have exactly 2 anyOf entries, not be double-wrapped") + val pass = obj(props["kiloPass"]) + val passAnyOf = arr(pass["anyOf"]) + assertEquals(2, passAnyOf.size, "kiloPass should still have exactly 2 anyOf entries, not be double-wrapped") } @Test diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt index 2c7c77a383c..40e8caa40ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt @@ -1,8 +1,24 @@ package ai.kilocode.client.settings.profile +import java.math.RoundingMode import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale -private val FMT = DecimalFormat("\$#,##0.00") +private val SYMBOLS = DecimalFormatSymbols(Locale.US) +private val FMT = DecimalFormat("\$#,##0.00", SYMBOLS) +private val SHORT = DecimalFormat("\$#,##0", SYMBOLS).apply { roundingMode = RoundingMode.HALF_UP } +private val DATE = DateTimeFormatter.ofPattern("MMM d", Locale.US).withZone(ZoneOffset.UTC) /** Format a USD balance value for display (e.g. `$1,234.56`). */ internal fun formatBalance(value: Double): String = FMT.format(value) + +internal fun formatShortBalance(value: Double): String = SHORT.format(value) + +internal fun formatResetDate(iso: String?): String? { + if (iso.isNullOrBlank()) return null + return runCatching { DATE.format(Instant.parse(iso)) }.getOrNull() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt index c3019b12721..520aa26ae71 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt @@ -9,15 +9,21 @@ import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileKiloPassDto import com.intellij.icons.AllIcons import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.IconLoader import com.intellij.ui.RelativeFont +import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.BorderLayout +import java.awt.Graphics +import java.awt.Graphics2D import java.awt.KeyboardFocusManager +import java.awt.RenderingHints import java.awt.event.FocusEvent import java.awt.event.FocusListener import javax.swing.DefaultComboBoxModel @@ -25,7 +31,7 @@ import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.SwingConstants -import java.awt.BorderLayout +import kotlin.math.roundToInt /** * Retained logged-in UI. Labels, combo box, and buttons are built once and @@ -33,6 +39,8 @@ import java.awt.BorderLayout */ internal class LoggedInProfileUi( private val dashboard: () -> Unit, + private val topUp: () -> Unit, + private val pass: () -> Unit, private val logout: () -> Unit, private val organization: (String?) -> Unit, private val refresh: () -> Unit, @@ -70,12 +78,66 @@ internal class LoggedInProfileUi( } } + private val passTitleLabel = JBLabel(KiloBundle.message("profile.pass.title")).apply { + font = UiStyle.Fonts.bold() + } + private val passUsageLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + font = UiStyle.Fonts.small() + } + private val passBonusLabel = JBLabel(KiloBundle.message("profile.pass.bonusLabel")).apply { + foreground = UiStyle.Colors.weak() + font = UiStyle.Fonts.small() + } + private val passBonusValue = JBLabel().apply { + foreground = UiStyle.Colors.weak() + font = UiStyle.Fonts.small() + } + private val passRenewLabel = JBLabel(KiloBundle.message("profile.pass.renewsLabel")).apply { + foreground = UiStyle.Colors.weak() + font = UiStyle.Fonts.small() + } + private val passRenewValue = JBLabel().apply { + foreground = UiStyle.Colors.weak() + font = UiStyle.Fonts.small() + } + private val passMeter = PassMeter().apply { + name = "kilo.profile.passMeter" + } + private val passBonusRow = Stack.horizontal(UiStyle.Gap.md()) + .next(passBonusLabel) + .fill(UiStyle.Gap.md()) + .next(passBonusValue) + private val passRenewRow = Stack.horizontal(UiStyle.Gap.md()) + .next(passRenewLabel) + .fill(UiStyle.Gap.md()) + .next(passRenewValue) + private val passLink = ActionLink(KiloBundle.message("profile.pass.subscribe")) { + pass() + }.apply { + name = "kilo.profile.passSubscribe" + } + private val passInfo = Stack.vertical(UiStyle.Gap.sm()) + .next(Stack.horizontal(UiStyle.Gap.md()) + .next(passTitleLabel) + .fill(UiStyle.Gap.md()) + .next(passUsageLabel)) + .next(passMeter) + .next(passBonusRow) + .next(passRenewRow) + private val passPanel = Stack.vertical(UiStyle.Gap.md()).apply { + name = "kilo.profile.passPanel" + next(passInfo) + next(passLink.align(HAlign.LEFT, VAlign.CENTER)) + } + private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { name = "kilo.profile.balanceCard" addToTop(titleLabel) addToCenter(Stack.vertical(UiStyle.Gap.pad()) .next(valueLabel) .next(refreshBtn) + .next(passPanel) .align(HAlign.CENTER, VAlign.CENTER)) } @@ -84,11 +146,14 @@ internal class LoggedInProfileUi( val dashboardBtn = JButton(KiloBundle.message("profile.action.dashboard")) .also { it.addActionListener { dashboard() } } + val topUpBtn = JButton(KiloBundle.message("profile.action.topUp")) + .also { it.addActionListener { topUp() } } val logoutBtn = JButton(KiloBundle.message("profile.action.logout")) .also { it.addActionListener { logout() } } private val actionRow = Stack.horizontal(UiStyle.Gap.md()) .next(dashboardBtn) + .next(topUpBtn) .next(logoutBtn) private val header = JPanel(BorderLayout()).apply { @@ -161,26 +226,88 @@ internal class LoggedInProfileUi( if (showEmail && emailLabel.text != profile.email) emailLabel.text = profile.email val bal = profile.balance + val personal = profile.currentOrgId == null + val item = if (personal) profile.kiloPass else null + val showPass = personal && (bal != null || item != null) var changed = false + changed = visible(titleLabel, bal != null) || changed + changed = visible(valueLabel, bal != null) || changed + changed = visible(refreshBtn, bal != null || showPass) || changed if (bal != null) { val balText = formatBalance(bal.balance) if (valueLabel.text != balText) { valueLabel.text = balText changed = true } - if (!balanceCard.isVisible) { - balanceCard.isVisible = true + } + changed = syncPass(item, showPass) || changed + changed = visible(balanceCard, bal != null || showPass) || changed + + applyOrganizations(profile) + if (changed) syncLayout() + } + + @RequiresEdt + private fun syncPass(item: ProfileKiloPassDto?, show: Boolean): Boolean { + var changed = false + val info = show && item != null + if (passInfo.isVisible != info) { + passInfo.isVisible = info + changed = true + } + if (item != null) { + val usage = KiloBundle.message( + "profile.pass.used", + formatShortBalance(item.currentPeriodUsageUsd), + formatShortBalance(item.currentPeriodBaseCreditsUsd), + ) + if (passUsageLabel.text != usage) { + passUsageLabel.text = usage + changed = true + } + val base = item.currentPeriodBaseCreditsUsd.coerceAtLeast(1.0) + passMeter.setFraction(item.currentPeriodUsageUsd / base) + val bonus = if (item.currentPeriodBonusCreditsUsd > 0.0) { + "+${formatBalance(item.currentPeriodBonusCreditsUsd)}" + } else null + if (passBonusValue.text != bonus.orEmpty()) { + passBonusValue.text = bonus.orEmpty() + changed = true + } + val bonusVisible = bonus != null + if (passBonusRow.isVisible != bonusVisible) { + passBonusRow.isVisible = bonusVisible + changed = true + } + val reset = formatResetDate(item.nextBillingAt) + if (passRenewValue.text != reset.orEmpty()) { + passRenewValue.text = reset.orEmpty() changed = true } - } else { - if (balanceCard.isVisible) { - balanceCard.isVisible = false + val renewVisible = reset != null + if (passRenewRow.isVisible != renewVisible) { + passRenewRow.isVisible = renewVisible changed = true } } + val link = show && item == null + if (passLink.isVisible != link) { + passLink.isVisible = link + changed = true + } + val panel = info || link + if (passPanel.isVisible != panel) { + passPanel.isVisible = panel + changed = true + } + return changed + } - applyOrganizations(profile) - if (changed) syncLayout() + @RequiresEdt + private fun visible(component: JComponent, show: Boolean): Boolean { + if (component.isVisible == show) return false + component.isVisible = show + return true } @RequiresEdt @@ -258,4 +385,34 @@ internal class LoggedInProfileUi( } } } + + private class PassMeter : JComponent() { + private var fraction = 0.0 + + @RequiresEdt + fun setFraction(value: Double) { + val next = value.coerceIn(0.0, 1.0) + if (fraction == next) return + fraction = next + repaint() + } + + override fun getPreferredSize() = JBUI.size(160, 6) + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = JBUI.scale(6) + g2.color = UiStyle.Colors.contentBorder() + g2.fillRoundRect(0, 0, width, height, arc, arc) + val fill = (width * fraction).roundToInt() + if (fill <= 0) return + g2.color = JBUI.CurrentTheme.Link.Foreground.ENABLED + g2.fillRoundRect(0, 0, fill, height, arc, arc) + } finally { + g2.dispose() + } + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt index 096f57fdbd7..8e942bdd99d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -27,6 +27,8 @@ import javax.swing.JComponent import javax.swing.JPanel internal const val DASHBOARD_URL = "https://app.kilo.ai/profile" +internal const val TOP_UP_URL = "https://app.kilo.ai/credits" +internal const val PASS_URL = "https://kilo.ai/pricing/kilo-pass" internal val edt = Dispatchers.EDT + ModalityState.any().asContextElement() @@ -62,6 +64,14 @@ internal class ProfileUi( telemetry("Dashboard Opened", mapOf("surface" to "settings")) browse(DASHBOARD_URL) }, + topUp = { + telemetry("Credits Opened", mapOf("surface" to "settings")) + browse(TOP_UP_URL) + }, + pass = { + telemetry("Kilo Pass Opened", mapOf("surface" to "settings")) + browse(PASS_URL) + }, logout = ::logout, organization = ::organization, refresh = ::refreshProfile, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt index 761b5878676..5b237c56416 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/UserProfileConfigurable.kt @@ -19,8 +19,8 @@ import javax.swing.JComponent * * Located at Settings -> Tools -> Kilo -> User Profile. * - * Shows login / logout, current balance, personal/org account selector, - * and a link to the Kilo dashboard. This is a status/action panel — it + * Shows login / logout, current balance, Kilo Pass, personal/org account selector, + * and account billing actions. This is a status/action panel — it * has no persistent settings, so [isModified] always returns false. */ class UserProfileConfigurable : KiloReadyConfigurable() { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 0cf258b2850..8ef217d0c31 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -323,9 +323,15 @@ profile.switchingAccount=Switching account... profile.action.login=Login with Kilo Code profile.action.logout=Log Out profile.action.dashboard=Dashboard +profile.action.topUp=Top up profile.action.retry=Retry profile.action.refresh=Refresh profile.action.refreshing=Refreshing.... +profile.pass.title=Kilo Pass +profile.pass.used={0} / {1} +profile.pass.bonusLabel=Bonus +profile.pass.renewsLabel=Renews +profile.pass.subscribe=Get Kilo Pass to add credits and earn bonuses profile.login.signingIn=Signing in to Kilo Code profile.login.urlLabel=Open this URL: profile.login.codeLabel=Enter this code: diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt index 37a25b01eca..2cc374decbd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.settings import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.settings.profile.ProfileUi +import ai.kilocode.client.settings.profile.formatResetDate +import ai.kilocode.client.settings.profile.formatShortBalance import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.KiloAppStateDto @@ -9,6 +11,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.LoadProgressDto import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileKiloPassDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import ai.kilocode.rpc.dto.ProfileStatusDto import com.intellij.openapi.application.ApplicationManager @@ -24,11 +27,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.awt.Component import java.awt.Container +import java.awt.image.BufferedImage +import java.util.TimeZone import javax.swing.AbstractButton import javax.swing.JComboBox +import javax.swing.JComponent import javax.swing.JEditorPane import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.RepaintManager import javax.swing.JTextField import javax.swing.SwingConstants import javax.swing.SwingUtilities @@ -179,6 +186,218 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { } } + fun `test personal profile shows kilo pass usage`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(267.59), + kiloPass = ProfileKiloPassDto( + currentPeriodBaseCreditsUsd = 199.0, + currentPeriodUsageUsd = 73.27, + currentPeriodBonusCreditsUsd = 99.5, + nextBillingAt = "2026-07-01T00:00:00.000Z", + ), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Kilo Pass")) + assertTrue(t, t.contains("$73 / $199")) + assertTrue(t, t.contains("Bonus")) + assertTrue(t, t.contains("+$99.50")) + assertTrue(t, t.contains("Renews")) + assertTrue(t, t.contains("Jul 1")) + assertTrue("pass meter should be visible", panelsByName(panel, "kilo.profile.passPanel").single().isVisible) + assertFalse(t, t.contains("Get Kilo Pass")) + } + } + + fun `test kilo pass short amounts use half up rounding`() { + assertEquals("$3", formatShortBalance(2.5)) + assertEquals("$2", formatShortBalance(2.49)) + } + + fun `test kilo pass hides empty bonus and invalid renewal rows`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(267.59), + kiloPass = ProfileKiloPassDto( + currentPeriodBaseCreditsUsd = 199.0, + currentPeriodUsageUsd = 73.27, + currentPeriodBonusCreditsUsd = 99.5, + nextBillingAt = "2026-07-01T00:00:00.000Z", + ), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Bonus")) + assertTrue(t, t.contains("Renews")) + } + + val invalid = profile.copy( + kiloPass = profile.kiloPass?.copy( + currentPeriodBonusCreditsUsd = 0.0, + nextBillingAt = "not-a-date", + ), + ) + edt { panel.update(invalid, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertFalse(t, t.contains("Bonus")) + assertFalse(t, t.contains("Renews")) + assertFalse(t, t.contains("+$99.50")) + assertFalse(t, t.contains("Jul 1")) + } + + val none = invalid.copy(kiloPass = invalid.kiloPass?.copy(nextBillingAt = null)) + edt { panel.update(none, KiloAppStatusDto.READY) } + + edt { + assertFalse(text(panel).contains("Renews")) + } + } + + fun `test kilo pass meter clamps base zero and avoids noop repaint`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(267.59), + kiloPass = ProfileKiloPassDto( + currentPeriodBaseCreditsUsd = 0.0, + currentPeriodUsageUsd = 0.0, + currentPeriodBonusCreditsUsd = 0.0, + ), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + val meter = edt { componentsByName(panel, "kilo.profile.passMeter").single() } + val empty = edt { paint(meter) } + val bg = color(empty, empty.width - 4) + assertEquals("empty meter should not paint fill", bg, color(empty, empty.width / 2)) + + val full = profile.copy( + kiloPass = profile.kiloPass?.copy( + currentPeriodBaseCreditsUsd = 100.0, + currentPeriodUsageUsd = 250.0, + ), + ) + edt { panel.update(full, KiloAppStatusDto.READY) } + + val filled = edt { paint(meter) } + assertFalse("clamped meter should fill the middle", bg == color(filled, filled.width / 2)) + assertFalse("clamped meter should fill the right edge", bg == color(filled, filled.width - 4)) + + val repaint = TrackingRepaintManager(meter) + val old = RepaintManager.currentManager(meter) + try { + RepaintManager.setCurrentManager(repaint) + edt { panel.update(full, KiloAppStatusDto.READY) } + assertEquals(0, repaint.dirty) + assertEquals(0, repaint.invalid) + } finally { + RepaintManager.setCurrentManager(old) + } + } + + fun `test personal profile shows kilo pass without balance`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + kiloPass = ProfileKiloPassDto( + currentPeriodBaseCreditsUsd = 199.0, + currentPeriodUsageUsd = 73.27, + currentPeriodBonusCreditsUsd = 99.5, + nextBillingAt = "2026-07-01T00:00:00.000Z", + ), + ) + val updated = profile.copy( + kiloPass = profile.kiloPass?.copy(currentPeriodUsageUsd = 88.0), + ) + rpc.fakeProfile = updated + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Kilo Pass")) + assertTrue(t, t.contains("$73 / $199")) + assertTrue(t, t.contains("Jul 1")) + assertFalse(t, t.contains("BALANCE")) + assertTrue("pass panel should be visible", panelsByName(panel, "kilo.profile.passPanel").single().isVisible) + buttons(panel).first { it.text == "Refresh" }.doClick() + assertTrue(text(panel).contains("Refreshing....")) + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("$88 / $199")) + assertTrue(t, t.contains("Refresh")) + } + } + + fun `test personal profile without kilo pass shows subscribe link`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Get Kilo Pass to add credits and earn bonuses")) + buttons(panel).first { it.text == "Dashboard" }.doClick() + buttons(panel).first { it.text == "Top up" }.doClick() + buttons(panel).first { it.text == "Get Kilo Pass to add credits and earn bonuses" }.doClick() + } + + assertEquals(listOf("https://app.kilo.ai/profile", "https://app.kilo.ai/credits", "https://kilo.ai/pricing/kilo-pass"), urls) + } + + fun `test kilo pass renewal date uses utc`() { + val zone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + assertEquals("Jul 1", formatResetDate("2026-07-01T00:00:00.000Z")) + } finally { + TimeZone.setDefault(zone) + } + } + + fun `test org profile hides kilo pass`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + currentOrgId = "org_1", + balance = ProfileBalanceDto(25.0), + kiloPass = ProfileKiloPassDto( + currentPeriodBaseCreditsUsd = 199.0, + currentPeriodUsageUsd = 73.27, + currentPeriodBonusCreditsUsd = 99.5, + ), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertFalse(t, t.contains("Kilo Pass")) + assertFalse(t, t.contains("Get Kilo Pass")) + } + } + fun `test refresh updates balance UI`() { val profile = ProfileDto( email = "alice@test.com", @@ -828,6 +1047,13 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { } } + private fun componentsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JComponent && comp.name == name) add(comp) + if (comp is Container) addAll(componentsByName(comp, name)) + } + } + private fun panels(root: Container): List = buildList { if (root is JPanel) add(root) for (comp in root.components) { @@ -883,6 +1109,21 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { } } + private fun paint(component: JComponent): BufferedImage { + val image = BufferedImage(100, 6, BufferedImage.TYPE_INT_ARGB) + component.setSize(image.width, image.height) + val g = image.createGraphics() + try { + component.paint(g) + } finally { + g.dispose() + } + return image + } + + private fun color(image: BufferedImage, x: Int): Int = + image.getRGB(x, image.height / 2) + private fun editorPanes(root: Container): List = buildList { for (comp in root.components) { if (!comp.isVisible) continue @@ -913,4 +1154,19 @@ class UserProfileConfigurableTest : BasePlatformTestCase() { if (comp is Container) collectText(comp, acc) } } + + private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() { + var dirty = 0 + var invalid = 0 + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c === watched) dirty++ + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent === watched) invalid++ + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index bf4ee1491ea..c8acff702ee 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -78,12 +78,21 @@ data class ProfileBalanceDto( val balance: Double, ) +@Serializable +data class ProfileKiloPassDto( + val currentPeriodBaseCreditsUsd: Double, + val currentPeriodUsageUsd: Double, + val currentPeriodBonusCreditsUsd: Double, + val nextBillingAt: String? = null, +) + @Serializable data class ProfileDto( val email: String, val name: String? = null, val organizations: List = emptyList(), val balance: ProfileBalanceDto? = null, + val kiloPass: ProfileKiloPassDto? = null, val currentOrgId: String? = null, )