diff --git a/.changeset/fix-command-template-serialization.md b/.changeset/fix-command-template-serialization.md new file mode 100644 index 00000000000..eb158322998 --- /dev/null +++ b/.changeset/fix-command-template-serialization.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains startup when command templates are returned as lazy objects. diff --git a/.changeset/jetbrains-logged-out-account-panel.md b/.changeset/jetbrains-logged-out-account-panel.md new file mode 100644 index 00000000000..67599a66745 --- /dev/null +++ b/.changeset/jetbrains-logged-out-account-panel.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show the logged-out account status in the same rounded panel as the logged-in account overlay, with a "Not logged in" label, hidden picker/balance, and a profile icon to open settings. diff --git a/.changeset/jetbrains-login-dismiss.md b/.changeset/jetbrains-login-dismiss.md new file mode 100644 index 00000000000..4df6bb9e3b3 --- /dev/null +++ b/.changeset/jetbrains-login-dismiss.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a Dismiss button to the paid-model sign-in prompt so users can close it and choose a different model. diff --git a/.changeset/jetbrains-login-qr.md b/.changeset/jetbrains-login-qr.md new file mode 100644 index 00000000000..2e3447eee2e --- /dev/null +++ b/.changeset/jetbrains-login-qr.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains sign-in UI: step labels are left-aligned, the URL field selects all on click, copying the URL or device code shows a confirmation balloon, and the click-to-copy code card and balance card share the same themed background and border. diff --git a/.changeset/jetbrains-paid-model-login.md b/.changeset/jetbrains-paid-model-login.md new file mode 100644 index 00000000000..f2ed31b2e8a --- /dev/null +++ b/.changeset/jetbrains-paid-model-login.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a sign-in prompt in JetBrains sessions when a paid model requires login. diff --git a/.changeset/jetbrains-profile-polish.md b/.changeset/jetbrains-profile-polish.md new file mode 100644 index 00000000000..5af1f098434 --- /dev/null +++ b/.changeset/jetbrains-profile-polish.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Polish the JetBrains user profile settings layout with a compact account stack, copyable email, simplified organization names, and a refreshable balance card. diff --git a/.changeset/jetbrains-profile-settings.md b/.changeset/jetbrains-profile-settings.md new file mode 100644 index 00000000000..7c715449215 --- /dev/null +++ b/.changeset/jetbrains-profile-settings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add native Kilo profile settings page to JetBrains plugin. Settings > Tools > Kilo > User Profile shows login/logout, balance, personal/org account switching, and a dashboard link, and refreshes immediately after login, logout, or active account changes. A new Profile button in the tool window toolbar opens the page directly. The profile settings page now keeps its native UI mounted while login state and active account details change. diff --git a/.changeset/jetbrains-session-account-overlay.md b/.changeset/jetbrains-session-account-overlay.md new file mode 100644 index 00000000000..600bc055108 --- /dev/null +++ b/.changeset/jetbrains-session-account-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show account login, switching, and balance controls on the empty JetBrains session screen. diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 6f6576fb3a8..13112339cd5 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -171,6 +171,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present in `backend/build/generated/cli/`; run `bun run build --prepare-cli` first). - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. - **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. +- **Run split backend**: `./gradlew runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. - **Test split mode**: `./gradlew generateSplitModeRunConfigurations` creates a "Run IDE (Split Mode)" config that starts both frontend and backend processes locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). ## UI Guidelines diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index f8f7150705f..146feaba28e 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -92,6 +92,8 @@ Production packaging still requires running `bun run build:production` so all pl Use the checked-in `Run IDE (Backend)` run configuration (or `./gradlew runIdeBackend`) to launch just the backend half of a split-mode session. It prepares the local-platform CLI binary automatically when `backend/build/generated/cli/cli/` does not contain the expected binary. +If `Run IDE (Backend)` exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting the backend. + Use `Run IDE (Split Mode)` to launch both halves at once (composes `Run IDE (Backend)` + `Run IDE (Frontend)`). ### Backend Gradle properties diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index e3e374c33e1..fb0f6d8430e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -13,6 +13,9 @@ import ai.kilocode.jetbrains.api.model.Config import ai.kilocode.jetbrains.api.model.ConfigWarnings200ResponseInner import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner import ai.kilocode.jetbrains.api.model.KiloProfile200Response +import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest +import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest +import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service @@ -27,10 +30,18 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex -import okhttp3.OkHttpClient +import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.net.ConnectException import java.net.SocketTimeoutException import java.util.concurrent.CopyOnWriteArrayList @@ -550,6 +561,89 @@ class KiloBackendAppService private constructor( _appState.value = KiloAppState.Disconnected } + /** + * Refresh the user profile from the CLI backend. + * Returns the latest profile data, or null when not logged in. + * Updates the current [KiloAppState.Ready] profile in-place if the app is ready. + */ + suspend fun refreshProfile(): KiloProfile200Response? { + val result = fetchProfile() + val fresh = result.value + val current = _appState.value + if (current is KiloAppState.Ready) { + setAppReady(current.data.copy(profile = fresh)) + } + profile = fresh + return fresh + } + + /** + * Start the Kilo device auth login flow. + * Returns [DeviceAuthDto] containing the verification URL and code for display in the UI. + */ + suspend fun startLogin(directory: String?): DeviceAuthDto { + val client = connection.api ?: throw IllegalStateException("Not connected") + val body = ProviderOauthAuthorizeRequest(method = 0.0) + val response = client.providerOauthAuthorize(providerID = "kilo", directory = directory, providerOauthAuthorizeRequest = body) + val match = response.instructions.let { Regex("""code:\s*(\S+)""", RegexOption.IGNORE_CASE).find(it) } + return DeviceAuthDto( + code = match?.groupValues?.get(1), + verificationUrl = response.url, + expiresIn = 900, + ) + } + + /** + * Complete the Kilo device auth login flow. + * Blocks until the user completes authentication on the browser side. + * Returns the user profile on success, or null if the login could not be completed. + */ + suspend fun completeLogin(directory: String?): KiloProfile200Response? { + val client = connection.api ?: throw IllegalStateException("Not connected") + client.providerOauthCallback(providerID = "kilo", directory = directory, providerOauthCallbackRequest = ProviderOauthCallbackRequest(method = 0.0)) + return refreshProfile() + } + + /** + * Log out from Kilo Gateway. + * Removes credentials and clears the profile from app state. + */ + suspend fun logout(): Boolean { + val client = connection.api ?: throw IllegalStateException("Not connected") + val result = client.authRemove(providerID = "kilo") + val current = _appState.value + if (current is KiloAppState.Ready) { + profile = null + setAppReady(current.data.copy(profile = null)) + } + return result + } + + /** + * Switch the active account context. + * Pass null for personal account, an organization ID for org context. + * Returns the updated profile after the switch. + */ + suspend fun setOrganization(organizationId: String?): KiloProfile200Response? { + val http = connection.apiClient ?: throw IllegalStateException("Not connected") + val body = JsonObject( + mapOf("organizationId" to (organizationId?.let { JsonPrimitive(it) } ?: JsonNull)), + ).toString() + val request = Request.Builder() + .url("http://127.0.0.1:$port/kilo/organization") + .header("Accept", "application/json") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + withContext(Dispatchers.IO) { + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Organization switch failed: HTTP ${response.code} ${response.message}") + } + } + } + return refreshProfile() + } + override fun dispose() { watcher?.cancel() watcher = null diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt index c73ebd0c1fa..3cbd48dd7ff 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendModelStateManager.kt @@ -9,10 +9,6 @@ import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import java.nio.file.Files @@ -29,7 +25,6 @@ class KiloBackendModelStateManager( private val DEFAULT_DIR = Path.of(System.getProperty("user.home"), ".local", "state", "kilo") } - private val json = Json { ignoreUnknownKeys = true } private val mutex = Mutex() private var client: OkHttpClient? = null @@ -122,8 +117,7 @@ class KiloBackendModelStateManager( return null } val raw = response.body?.string() ?: return null - val state = json.parseToJsonElement(raw).jsonObject["state"]?.jsonPrimitive?.contentOrNull - val dir = state?.let(Path::of) ?: DEFAULT_DIR + val dir = KiloCliDataParser.parsePathState(raw)?.let(Path::of) ?: DEFAULT_DIR Files.createDirectories(dir) dir.resolve("model.json").also { file = it } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 9e097feae5c..d1646c33548 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -1,5 +1,10 @@ package ai.kilocode.backend.cli +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.ModelInfo +import ai.kilocode.backend.workspace.ModelLimitInfo +import ai.kilocode.backend.workspace.ProviderData +import ai.kilocode.backend.workspace.ProviderInfo import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.CloudSessionListDto @@ -286,6 +291,45 @@ object KiloCliDataParser { ) } + /** + * Parse a provider catalog response (`GET /provider`) into [ProviderData]. + * Throws if [raw] is not a valid JSON object (lets the workspace loading + * catch the exception and surface it as a LoadError). + */ + fun parseProviders(raw: String): ProviderData { + val obj = json.parseToJsonElement(raw).jsonObject + return ProviderData( + providers = obj["all"]?.jsonArray?.map { parseProvider(it.jsonObject) } ?: emptyList(), + connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + defaults = obj["default"]?.jsonObject?.mapValues { (_, v) -> v.jsonPrimitive.content } ?: emptyMap(), + ) + } + + /** + * Parse a command list response (`GET /command`) into a list of [CommandInfo]. + * The `template` field is intentionally ignored — CLI commands can return lazy + * promise objects (`{}`) for that field, which must not crash JetBrains startup. + */ + fun parseCommands(raw: String): List = + json.parseToJsonElement(raw).jsonArray.map { item -> + val obj = item.jsonObject + CommandInfo( + name = obj.str("name") ?: "", + description = obj.str("description"), + source = obj.str("source"), + hints = obj["hints"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + ) + } + + /** + * Extract the `state` directory path from a `/path` response. + * Returns `null` when the field is missing, not a JSON string, or the JSON is malformed. + */ + fun parsePathState(raw: String): String? { + val prim = runCatching { tryParseObject(raw)?.get("state")?.jsonPrimitive }.getOrNull() ?: return null + return if (prim.isString) prim.content else null + } + fun parseModelState(raw: String): ModelStateDto { val obj = tryParseObject(raw) ?: return ModelStateDto() return ModelStateDto( @@ -327,6 +371,14 @@ object KiloCliDataParser { } val sb = StringBuilder() sb.append("""{"parts":[$parts]""") + val msg = prompt.messageID + if (msg != null) { + sb.append(""","messageID":${escape(msg)}""") + } + val reply = prompt.noReply + if (reply != null) { + sb.append(""","noReply":$reply""") + } val pid = prompt.providerID val mid = prompt.modelID if (pid != null && mid != null) { @@ -441,10 +493,16 @@ object KiloCliDataParser { internal fun parseError(obj: JsonObject): MessageErrorDto { val type = obj.str("type") ?: obj.str("name") ?: "unknown" + val data = obj["data"]?.jsonObject val msg = obj.str("message") - ?: obj["data"]?.jsonObject?.str("message") + ?: data?.str("message") ?: obj.str("error") - return MessageErrorDto(type, msg) + return MessageErrorDto( + type, + msg, + statusCode = data?.long("statusCode")?.safeInt(), + responseBody = data?.str("responseBody"), + ) } internal fun parsePermissionRequest(obj: JsonObject): PermissionRequestDto? { @@ -520,6 +578,49 @@ object KiloCliDataParser { "modelID" to JsonPrimitive(item.modelID), )) + // ================================================================ + // Internal — provider/catalog parsing + // ================================================================ + + private val EFFORT_ORDER = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max") + .withIndex().associate { it.value to it.index } + + private fun parseProvider(obj: JsonObject) = ProviderInfo( + id = obj.str("id") ?: "", + name = obj.str("name") ?: "", + source = obj.str("source"), + models = obj["models"]?.jsonObject?.mapValues { (id, v) -> parseModel(id, v.jsonObject) } ?: emptyMap(), + ) + + private fun parseModel(id: String, obj: JsonObject): ModelInfo { + val cap = obj["capabilities"]?.jsonObject + val limit = obj["limit"]?.jsonObject + return ModelInfo( + id = obj.str("id") ?: id, + name = obj.str("name") ?: id, + attachment = cap.bool("attachment"), + reasoning = cap.bool("reasoning"), + temperature = cap.bool("temperature"), + toolCall = cap.bool("toolcall"), + free = obj.bool("isFree"), + status = obj.str("status"), + recommendedIndex = obj.num("recommendedIndex"), + variants = parseVariants(obj), + limit = limit?.let { + ModelLimitInfo( + context = it.long("context") ?: 0, + input = it.long("input"), + output = it.long("output") ?: 0, + ) + }, + ) + } + + private fun parseVariants(obj: JsonObject): List { + val keys = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList() + return keys.sortedWith(compareBy { EFFORT_ORDER[it] ?: Int.MAX_VALUE }.thenBy { it }) + } + private fun parseSessionObject(obj: JsonObject): SessionDto { val time = obj["time"]?.jsonObject val summary = obj["summary"]?.jsonObject @@ -679,6 +780,9 @@ private fun JsonObject.num(key: String): Double? = private fun JsonObject.long(key: String): Long? = this[key]?.jsonPrimitive?.longOrNull +private fun JsonObject?.bool(key: String): Boolean = + this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false + private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() private fun JsonObject?.map(key: String): Map { 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 27358ac843a..c02afa53371 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 @@ -11,10 +11,12 @@ import ai.kilocode.backend.app.ProfileResult import ai.kilocode.jetbrains.api.model.AgentConfig import ai.kilocode.jetbrains.api.model.Config import ai.kilocode.jetbrains.api.model.ConfigAgent +import ai.kilocode.jetbrains.api.model.KiloProfile200Response import ai.kilocode.rpc.dto.AgentConfigDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.dto.ConfigWarningDto +import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -24,6 +26,9 @@ import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto import ai.kilocode.rpc.dto.ModelSelectionUpdateDto 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.ProfileOrganizationDto import ai.kilocode.rpc.dto.ProfileStatusDto import com.intellij.openapi.components.service import kotlinx.coroutines.flow.Flow @@ -63,6 +68,17 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto = app.models.variant(update) + override suspend fun refreshProfile(): ProfileDto? = app.refreshProfile()?.let(::profileDto) + + override suspend fun startLogin(directory: String?): DeviceAuthDto = app.startLogin(directory) + + override suspend fun completeLogin(directory: String?): ProfileDto? = app.completeLogin(directory)?.let(::profileDto) + + override suspend fun logout(): Boolean = app.logout() + + override suspend fun setOrganization(organizationId: String?): ProfileDto? = + app.setOrganization(organizationId)?.let(::profileDto) + private fun dto(state: KiloAppState): KiloAppStateDto = appStateDto(state) } @@ -85,6 +101,7 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto = ), warnings = state.data.warnings.map(::warning), config = config(state.data.config), + profile = state.data.profile?.let(::profileDto), ) is KiloAppState.Error -> KiloAppStateDto( status = KiloAppStatusDto.ERROR, @@ -93,6 +110,16 @@ internal fun appStateDto(state: KiloAppState): KiloAppStateDto = ) } +internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto( + email = p.profile.email, + name = p.profile.name, + organizations = p.profile.organizations.orEmpty().map { org -> + ProfileOrganizationDto(id = org.id, name = org.name, role = org.role) + }, + balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) }, + currentOrgId = p.currentOrgId, +) + private fun progress(p: LoadProgress) = LoadProgressDto( config = p.config, notifications = p.notifications, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index f5b41711f3e..5aa272cf72e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -3,20 +3,12 @@ package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.SseEvent +import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -57,10 +49,6 @@ class KiloBackendWorkspace( companion object { private const val MAX_RETRIES = 3 private const val RETRY_DELAY_MS = 1000L - private val json = Json { ignoreUnknownKeys = true } - private val EFFORT_ORDER = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max") - .withIndex() - .associate { it.value to it.index } } private val _state = MutableStateFlow(KiloWorkspaceState.Pending) @@ -219,7 +207,7 @@ class KiloBackendWorkspace( private fun fetchProviders(): FetchResult = try { - FetchResult.ok(parseProviders(fetch("/provider?directory=${encode(directory)}"))) + FetchResult.ok(KiloCliDataParser.parseProviders(fetch("/provider?directory=${encode(directory)}"))) } catch (e: Exception) { log.warn("Providers fetch failed: ${e.message}", e) FetchResult.fail("providers", e) @@ -242,14 +230,7 @@ class KiloBackendWorkspace( private fun fetchCommands(): FetchResult> = try { - FetchResult.ok(api.commandList(directory = directory).map { c -> - CommandInfo( - name = c.name, - description = c.description, - source = c.source?.value, - hints = c.hints, - ) - }) + FetchResult.ok(KiloCliDataParser.parseCommands(fetch("/command?directory=${encode(directory)}"))) } catch (e: Exception) { log.warn("Commands fetch failed: ${e.message}", e) FetchResult.fail("commands", e) @@ -282,51 +263,6 @@ class KiloBackendWorkspace( deprecated = a.deprecated, ) - private fun parseProviders(raw: String): ProviderData { - val obj = json.parseToJsonElement(raw).jsonObject - return ProviderData( - providers = obj["all"]?.jsonArray?.map { provider(it.jsonObject) } ?: emptyList(), - connected = obj["connected"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), - defaults = obj["default"]?.jsonObject?.mapValues { (_, value) -> value.jsonPrimitive.content } ?: emptyMap(), - ) - } - - private fun provider(obj: JsonObject) = ProviderInfo( - id = obj.str("id") ?: "", - name = obj.str("name") ?: "", - source = obj.str("source"), - models = obj["models"]?.jsonObject?.mapValues { (id, value) -> model(id, value.jsonObject) } ?: emptyMap(), - ) - - private fun model(id: String, obj: JsonObject): ModelInfo { - val cap = obj["capabilities"]?.jsonObject - val limit = obj["limit"]?.jsonObject - return ModelInfo( - id = obj.str("id") ?: id, - name = obj.str("name") ?: id, - attachment = cap.bool("attachment"), - reasoning = cap.bool("reasoning"), - temperature = cap.bool("temperature"), - toolCall = cap.bool("toolcall"), - free = obj.bool("isFree"), - status = obj.str("status"), - recommendedIndex = obj.num("recommendedIndex"), - variants = variants(obj), - limit = limit?.let { - ModelLimitInfo( - context = it.long("context") ?: 0, - input = it.long("input"), - output = it.long("output") ?: 0, - ) - }, - ) - } - - private fun variants(obj: JsonObject): List { - val raw = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList() - return raw.sortedWith(compareBy { EFFORT_ORDER[it] ?: Int.MAX_VALUE }.thenBy { it }) - } - private fun fetch(path: String): String { val request = Request.Builder().url("http://localhost:$port$path").get().build() http.newCall(request).execute().use { response -> @@ -371,7 +307,3 @@ class KiloBackendWorkspace( } private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8) -private fun JsonObject.str(key: String) = this[key]?.jsonPrimitive?.contentOrNull -private fun JsonObject?.bool(key: String) = this?.get(key)?.jsonPrimitive?.booleanOrNull ?: false -private fun JsonObject.num(key: String) = this[key]?.jsonPrimitive?.doubleOrNull -private fun JsonObject.long(key: String) = this[key]?.jsonPrimitive?.longOrNull diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index b3cfc1b486f..6864fb20f0f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -164,6 +164,22 @@ class KiloBackendAppServiceTest { assertEquals("alice@test.com", svc.profile!!.profile.email) } + @Test + fun `set organization sends explicit null body for personal account`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + svc.setOrganization("org_1") + assertEquals("""{"organizationId":"org_1"}""", mock.lastOrganizationSetBody) + + svc.setOrganization(null) + assertEquals("""{"organizationId":null}""", mock.lastOrganizationSetBody) + } + @Test fun `profile 401 does not prevent Ready`() = runBlocking { mock.profileStatus = 401 @@ -440,6 +456,41 @@ class KiloBackendAppServiceTest { assertTrue((svc.appState.value as KiloAppState.Ready).data.warnings.isEmpty()) } + // ------ Auth mapping tests ------ + + @Test + fun `start login maps device auth response`() = runBlocking { + // Default authorizeResponse: url=https://auth.kilo.ai/device, code=TEST-1234 + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val auth = svc.startLogin(null) + assertEquals("https://auth.kilo.ai/device", auth.verificationUrl) + assertEquals("TEST-1234", auth.code) + assertEquals(900, auth.expiresIn) + assertNotNull(mock.lastAuthorizeBody) + } + + @Test + fun `complete login calls callback and refreshes profile`() = runBlocking { + mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val profile = svc.completeLogin(null) + assertNotNull(profile) + assertEquals("alice@test.com", profile.profile.email) + assertNotNull(mock.lastCallbackBody) + } + // ------ Concurrency & lifecycle tests ------ @Test @@ -519,4 +570,150 @@ class KiloBackendAppServiceTest { assertIs(svc.appState.value) } + + // ------ Profile DTO mapping tests ------ + + @Test + fun `ready dto maps profile fields`() = runBlocking { + mock.profile = """{ + "profile":{ + "email":"alice@test.com", + "name":"Alice", + "organizations":[{"id":"org_1","name":"Acme","role":"ADMIN"}] + }, + "balance":{"balance":42.5}, + "currentOrgId":"org_1" + }""".trimIndent() + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val dto = appStateDto(svc.appState.value) + assertEquals("alice@test.com", dto.profile?.email) + assertEquals("Alice", dto.profile?.name) + assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role) + assertEquals(42.5, dto.profile?.balance?.balance) + assertEquals("org_1", dto.profile?.currentOrgId) + } + + @Test + fun `refresh profile updates ready dto profile`() = runBlocking { + mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + // Update mock to return different profile + mock.profile = """{"profile":{"email":"alice@test.com","name":"Updated Alice"},"balance":{"balance":99.0},"currentOrgId":null}""" + + val fresh = svc.refreshProfile() + assertNotNull(fresh) + assertEquals("Updated Alice", fresh.profile.name) + assertEquals("Updated Alice", appStateDto(svc.appState.value).profile?.name) + assertEquals(99.0, appStateDto(svc.appState.value).profile?.balance?.balance) + } + + @Test + fun `logout clears ready profile on success`() = runBlocking { + mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + assertNotNull(svc.profile) + mock.authRemoveStatus = 200 + val ok = svc.logout() + + assertTrue(ok) + assertNull(svc.profile) + assertNull(appStateDto(svc.appState.value).profile) + } + + @Test + fun `set organization failure leaves profile unchanged`() = runBlocking { + mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val before = svc.profile + assertNotNull(before) + + mock.organizationSetStatus = 500 + var thrown = false + try { + svc.setOrganization("org_1") + } catch (_: Exception) { + thrown = true + } + assertTrue(thrown, "setOrganization with 500 should throw") + // Profile should remain unchanged because organization switch failed before refreshProfile + assertEquals(before.profile.email, svc.profile?.profile?.email) + } + + @Test + fun `start login failure propagates`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + mock.authorizeStatus = 500 + var thrown = false + try { + svc.startLogin(null) + } catch (_: Exception) { + thrown = true + } + assertTrue(thrown, "startLogin with 500 status should throw") + } + + @Test + fun `start login without code returns null code but url present`() = runBlocking { + // Instructions without 'code:' — the regex match should return null + mock.authorizeResponse = """{"url":"https://auth.kilo.ai/device","method":"code","instructions":"Open the URL in your browser to sign in"}""" + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + val auth = svc.startLogin(null) + assertNull(auth.code, "code should be null when instructions have no code: prefix") + assertEquals("https://auth.kilo.ai/device", auth.verificationUrl) + } + + @Test + fun `complete login callback failure propagates`() = runBlocking { + val svc = create() + svc.connect() + + withTimeout(10_000) { + svc.appState.first { it is KiloAppState.Ready } + } + + mock.callbackStatus = 500 + var thrown = false + try { + svc.completeLogin(null) + } catch (_: Exception) { + thrown = true + } + assertTrue(thrown, "completeLogin with 500 callback status should throw") + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 6b1f3c980fe..03934123a03 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.backend.cli -import ai.kilocode.backend.cli.KiloCliDataParser +import ai.kilocode.backend.workspace.CommandInfo +import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto @@ -10,8 +11,10 @@ import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.QuestionReplyDto +import org.junit.jupiter.api.Nested import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -22,1122 +25,1350 @@ import kotlin.test.assertTrue * No mocks, no services, no coroutines — just JSON in → DTO out. * When a new parsing bug is found, copy the raw JSON that caused * the issue and add a test case here. + * + * Tests are grouped into three nested classes: + * - [SseEvents] — SSE/chat event parsing + * - [HttpResponses] — HTTP response body parsing + * - [RequestBuilders] — outgoing JSON body builders and local model state */ class KiloCliDataParserTest { // ================================================================ - // extractEventType + // Group 1 — SSE / chat event parsing // ================================================================ - @Test - fun `extractEventType - parses type from JSON data`() { - val result = KiloCliDataParser.extractEventType( - """{"type":"global.config.updated","payload":{}}""" - ) - assertEquals("global.config.updated", result) - } + @Nested + inner class SseEvents { - @Test - fun `extractEventType - returns unknown for missing type`() { - assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}""")) - } + // ---- extractEventType ---- - @Test - fun `extractEventType - returns unknown for empty string`() { - assertEquals("unknown", KiloCliDataParser.extractEventType("")) - } + @Test + fun `extractEventType - parses type from JSON data`() { + val result = KiloCliDataParser.extractEventType( + """{"type":"global.config.updated","payload":{}}""" + ) + assertEquals("global.config.updated", result) + } - // ================================================================ - // parseChatEvent — GlobalEvent wrapper - // ================================================================ + @Test + fun `extractEventType - returns unknown for missing type`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("""{"data":"something"}""")) + } - @Test - fun `parseChatEvent - message updated with GlobalEvent wrapper`() { - val data = """{ - "directory": "/tmp/test", - "payload": { + @Test + fun `extractEventType - returns unknown for empty string`() { + assertEquals("unknown", KiloCliDataParser.extractEventType("")) + } + + // ---- parseChatEvent — GlobalEvent wrapper ---- + + @Test + fun `parseChatEvent - message updated with GlobalEvent wrapper`() { + val data = """{ + "directory": "/tmp/test", + "payload": { + "type": "message.updated", + "properties": { + "sessionID": "ses_123", + "info": { + "id": "msg_1", + "sessionID": "ses_123", + "role": "assistant", + "time": { "created": 1000.0 } + } + } + } + }""" + + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_123", result.sessionID) + assertEquals("msg_1", result.info.id) + assertEquals("assistant", result.info.role) + } + + @Test + fun `parseChatEvent - flat event without payload wrapper`() { + val data = """{ "type": "message.updated", "properties": { - "sessionID": "ses_123", - "info": { - "id": "msg_1", - "sessionID": "ses_123", - "role": "assistant", - "time": { "created": 1000.0 } - } - } - } - }""" - - val result = KiloCliDataParser.parseChatEvent("message.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.MessageUpdated) - assertEquals("ses_123", result.sessionID) - assertEquals("msg_1", result.info.id) - assertEquals("assistant", result.info.role) - } - - @Test - fun `parseChatEvent - flat event without payload wrapper`() { - val data = """{ - "type": "message.updated", - "properties": { - "sessionID": "ses_456", - "info": { - "id": "msg_2", "sessionID": "ses_456", - "role": "user", - "time": { "created": 2000.0 } - } - } - }""" - - val result = KiloCliDataParser.parseChatEvent("message.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.MessageUpdated) - assertEquals("ses_456", result.sessionID) - assertEquals("user", result.info.role) - } - - // ================================================================ - // parseChatEvent — specific event types - // ================================================================ - - @Test - fun `parseChatEvent - message part delta`() { - val data = globalEvent(""" - "type": "message.part.delta", - "properties": { - "sessionID": "ses_1", - "messageID": "msg_1", - "partID": "part_1", - "field": "text", - "delta": "Hello world" - } - """) - - val result = KiloCliDataParser.parseChatEvent("message.part.delta", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartDelta) - assertEquals("ses_1", result.sessionID) - assertEquals("msg_1", result.messageID) - assertEquals("part_1", result.partID) - assertEquals("text", result.field) - assertEquals("Hello world", result.delta) - } - - @Test - fun `parseChatEvent - message part updated`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_1", - "sessionID": "ses_1", - "messageID": "msg_1", - "type": "text", - "text": "Hello" - } - } - """) - - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartUpdated) - assertEquals("ses_1", result.sessionID) - assertEquals("part_1", result.part.id) - assertEquals("text", result.part.type) - assertEquals("Hello", result.part.text) - } - - @Test - fun `parseChatEvent - read tool part preserves input metadata and time`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_read", - "sessionID": "ses_1", - "messageID": "msg_1", - "type": "tool", - "tool": "read", - "callID": "call_read", - "metadata": { "loaded": ["README.MD"] }, - "state": { - "status": "completed", - "input": { "filePath": "README.MD", "limit": 200 }, - "metadata": { "source": "workspace" }, - "title": "Read README.MD", - "time": { "start": 10, "end": 12 } + "info": { + "id": "msg_2", + "sessionID": "ses_456", + "role": "user", + "time": { "created": 2000.0 } } } - } - """) + }""" - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartUpdated) - assertEquals("read", result.part.tool) - assertEquals("completed", result.part.state) - assertEquals("Read README.MD", result.part.title) - assertEquals("README.MD", result.part.input["filePath"]) - assertEquals("200", result.part.input["limit"]) - assertEquals("workspace", result.part.metadata["source"]) - assertEquals("[\"README.MD\"]", result.part.metadata["loaded"]) - assertEquals(10.0, result.part.time?.start) - assertEquals(12.0, result.part.time?.end) - } + val result = KiloCliDataParser.parseChatEvent("message.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageUpdated) + assertEquals("ses_456", result.sessionID) + assertEquals("user", result.info.role) + } - @Test - fun `parseChatEvent - bash tool part preserves command output and error`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_bash", + // ---- parseChatEvent — specific event types ---- + + @Test + fun `parseChatEvent - message part delta`() { + val data = globalEvent(""" + "type": "message.part.delta", + "properties": { "sessionID": "ses_1", "messageID": "msg_1", - "type": "tool", - "tool": "bash", - "callID": "call_bash", - "state": { - "status": "error", - "input": { - "command": "git remote -v", - "description": "View git remote URLs" - }, - "metadata": { "command": "git remote -v" }, - "output": "origin git@example.com:repo.git", - "error": "exit code 1", - "time": { "start": 20, "end": 25 } + "partID": "part_1", + "field": "text", + "delta": "Hello world" + } + """) + + val result = KiloCliDataParser.parseChatEvent("message.part.delta", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartDelta) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + assertEquals("text", result.field) + assertEquals("Hello world", result.delta) + } + + @Test + fun `parseChatEvent - message part updated`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "text", + "text": "Hello" } } - } - """) + """) - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartUpdated) - assertEquals("bash", result.part.tool) - assertEquals("error", result.part.state) - assertEquals("git remote -v", result.part.input["command"]) - assertEquals("View git remote URLs", result.part.input["description"]) - assertEquals("origin git@example.com:repo.git", result.part.output) - assertEquals("exit code 1", result.part.error) - assertEquals(20.0, result.part.time?.start) - assertEquals(25.0, result.part.time?.end) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("part_1", result.part.id) + assertEquals("text", result.part.type) + assertEquals("Hello", result.part.text) + } - @Test - fun `parseChatEvent - turn open`() { - val data = globalEvent(""" - "type": "session.turn.open", - "properties": { "sessionID": "ses_1" } - """) + @Test + fun `parseChatEvent - read tool part preserves input metadata and time`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_read", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "read", + "callID": "call_read", + "metadata": { "loaded": ["README.MD"] }, + "state": { + "status": "completed", + "input": { "filePath": "README.MD", "limit": 200 }, + "metadata": { "source": "workspace" }, + "title": "Read README.MD", + "time": { "start": 10, "end": 12 } + } + } + } + """) - val result = KiloCliDataParser.parseChatEvent("session.turn.open", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.TurnOpen) - assertEquals("ses_1", result.sessionID) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("read", result.part.tool) + assertEquals("completed", result.part.state) + assertEquals("Read README.MD", result.part.title) + assertEquals("README.MD", result.part.input["filePath"]) + assertEquals("200", result.part.input["limit"]) + assertEquals("workspace", result.part.metadata["source"]) + assertEquals("[\"README.MD\"]", result.part.metadata["loaded"]) + assertEquals(10.0, result.part.time?.start) + assertEquals(12.0, result.part.time?.end) + } - @Test - fun `parseChatEvent - turn close`() { - val data = globalEvent(""" - "type": "session.turn.close", - "properties": { "sessionID": "ses_1", "reason": "completed" } - """) + @Test + fun `parseChatEvent - bash tool part preserves command output and error`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_bash", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "bash", + "callID": "call_bash", + "state": { + "status": "error", + "input": { + "command": "git remote -v", + "description": "View git remote URLs" + }, + "metadata": { "command": "git remote -v" }, + "output": "origin git@example.com:repo.git", + "error": "exit code 1", + "time": { "start": 20, "end": 25 } + } + } + } + """) - val result = KiloCliDataParser.parseChatEvent("session.turn.close", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.TurnClose) - assertEquals("ses_1", result.sessionID) - assertEquals("completed", result.reason) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartUpdated) + assertEquals("bash", result.part.tool) + assertEquals("error", result.part.state) + assertEquals("git remote -v", result.part.input["command"]) + assertEquals("View git remote URLs", result.part.input["description"]) + assertEquals("origin git@example.com:repo.git", result.part.output) + assertEquals("exit code 1", result.part.error) + assertEquals(20.0, result.part.time?.start) + assertEquals(25.0, result.part.time?.end) + } - @Test - fun `parseChatEvent - session error`() { - val data = globalEvent(""" - "type": "session.error", - "properties": { - "sessionID": "ses_1", - "error": { "type": "provider_auth", "message": "Invalid key" } - } - """) + @Test + fun `parseChatEvent - part updated with callID`() { + val data = globalEvent(""" + "type": "message.part.updated", + "properties": { + "sessionID": "ses_1", + "part": { + "id": "part_1", + "sessionID": "ses_1", + "messageID": "msg_1", + "type": "tool", + "tool": "bash", + "callID": "call_abc", + "state": { "status": "running" } + } + } + """) - val result = KiloCliDataParser.parseChatEvent("session.error", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.Error) - assertEquals("ses_1", result.sessionID) - assertEquals("provider_auth", result.error?.type) - assertEquals("Invalid key", result.error?.message) - } + val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated + assertEquals("call_abc", result.part.callID) + assertEquals("bash", result.part.tool) + } - @Test - fun `parseChatEvent - message removed`() { - val data = globalEvent(""" - "type": "message.removed", - "properties": { "sessionID": "ses_1", "messageID": "msg_1" } - """) + @Test + fun `parseChatEvent - turn open`() { + val data = globalEvent(""" + "type": "session.turn.open", + "properties": { "sessionID": "ses_1" } + """) - val result = KiloCliDataParser.parseChatEvent("message.removed", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.MessageRemoved) - assertEquals("ses_1", result.sessionID) - assertEquals("msg_1", result.messageID) - } + val result = KiloCliDataParser.parseChatEvent("session.turn.open", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnOpen) + assertEquals("ses_1", result.sessionID) + } - // ================================================================ - // parseChatEvent — error cases - // ================================================================ + @Test + fun `parseChatEvent - turn close`() { + val data = globalEvent(""" + "type": "session.turn.close", + "properties": { "sessionID": "ses_1", "reason": "completed" } + """) - @Test - fun `parseChatEvent - unknown type returns null`() { - val data = globalEvent(""" - "type": "some.unknown.event", - "properties": { "sessionID": "ses_1" } - """) - assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data)) - } + val result = KiloCliDataParser.parseChatEvent("session.turn.close", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TurnClose) + assertEquals("ses_1", result.sessionID) + assertEquals("completed", result.reason) + } - @Test - fun `parseChatEvent - malformed JSON returns null`() { - assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json")) - } + @Test + fun `parseChatEvent - session error`() { + val data = globalEvent(""" + "type": "session.error", + "properties": { + "sessionID": "ses_1", + "error": { "type": "provider_auth", "message": "Invalid key" } + } + """) - @Test - fun `parseChatEvent - missing properties returns null`() { - assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}""")) - } + val result = KiloCliDataParser.parseChatEvent("session.error", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.Error) + assertEquals("ses_1", result.sessionID) + assertEquals("provider_auth", result.error?.type) + assertEquals("Invalid key", result.error?.message) + } - @Test - fun `parseChatEvent - missing sessionID returns null`() { - val data = globalEvent(""" - "type": "message.updated", - "properties": { "info": { "id": "msg_1", "role": "user", "time": {} } } - """) - assertNull(KiloCliDataParser.parseChatEvent("message.updated", data)) - } + @Test + fun `parseChatEvent - session error preserves API error details`() { + val data = globalEvent(""" + "type": "session.error", + "properties": { + "sessionID": "ses_1", + "error": { + "name": "APIError", + "message": "Unauthorized", + "data": { + "statusCode": 401, + "responseBody": "{\"error\":{\"code\":\"PAID_MODEL_AUTH_REQUIRED\"}}" + } + } + } + """) - // ================================================================ - // parseSessionStatus - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("session.error", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.Error) + assertEquals("ses_1", result.sessionID) + assertEquals("APIError", result.error?.type) + assertEquals("Unauthorized", result.error?.message) + assertEquals(401, result.error?.statusCode) + assertEquals("""{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""", result.error?.responseBody) + } - @Test - fun `parseSessionStatus - valid status event`() { - val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""" - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("ses_abc", result.first) - assertEquals("busy", result.second.type) - assertEquals("Running...", result.second.message) - } + @Test + fun `parseChatEvent - message removed`() { + val data = globalEvent(""" + "type": "message.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1" } + """) - @Test - fun `parseSessionStatus - missing sessionID returns null`() { - val data = """{"status":{"type":"idle"}}""" - assertNull(KiloCliDataParser.parseSessionStatus(data)) - } + val result = KiloCliDataParser.parseChatEvent("message.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.MessageRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + } - @Test - fun `parseSessionStatus - missing status defaults to idle`() { - val data = """{"sessionID":"ses_xyz"}""" - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("idle", result.second.type) - assertNull(result.second.message) - } + @Test + fun `parseChatEvent - message part removed`() { + val data = globalEvent(""" + "type": "message.part.removed", + "properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" } + """) - // ================================================================ - // parseSession - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("message.part.removed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PartRemoved) + assertEquals("ses_1", result.sessionID) + assertEquals("msg_1", result.messageID) + assertEquals("part_1", result.partID) + } - @Test - fun `parseSession - full session response`() { - val raw = """{ - "id": "ses_abc", - "projectID": "proj_1", - "directory": "/tmp/project", - "parentID": null, - "title": "Test session", - "version": "1", - "time": { "created": 1000.0, "updated": 2000.0 }, - "summary": { "additions": 10, "deletions": 5, "files": 3 } - }""" + // ---- session lifecycle events ---- - val result = KiloCliDataParser.parseSession(raw) - assertEquals("ses_abc", result.id) - assertEquals("proj_1", result.projectID) - assertEquals("/tmp/project", result.directory) - assertNull(result.parentID) - assertEquals("Test session", result.title) - assertEquals(1000.0, result.time.created) - assertEquals(2000.0, result.time.updated) - assertNotNull(result.summary) - assertEquals(10, result.summary?.additions) - assertEquals(5, result.summary?.deletions) - assertEquals(3, result.summary?.files) - } + @Test + fun `parseChatEvent - session idle`() { + val data = globalEvent(""" + "type": "session.idle", + "properties": { "sessionID": "ses_1" } + """) + val result = KiloCliDataParser.parseChatEvent("session.idle", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionIdle) + assertEquals("ses_1", result.sessionID) + } - @Test - fun `parseSession - minimal session response`() { - val raw = """{ - "id": "ses_min", - "projectID": "proj_2", - "directory": "/tmp", - "title": "", - "version": "0", - "time": { "created": 0.0, "updated": 0.0 } - }""" + @Test + fun `parseChatEvent - session compacted`() { + val data = globalEvent(""" + "type": "session.compacted", + "properties": { "sessionID": "ses_1" } + """) + val result = KiloCliDataParser.parseChatEvent("session.compacted", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionCompacted) + } - val result = KiloCliDataParser.parseSession(raw) - assertEquals("ses_min", result.id) - assertNull(result.summary) - } + @Test + fun `parseChatEvent - session updated`() { + val data = globalEvent(""" + "type": "session.updated", + "properties": { + "sessionID": "ses_1", + "info": { + "id": "ses_1", + "projectID": "proj_1", + "directory": "/tmp/project", + "title": "Updated title", + "version": "1", + "time": { "created": 1.0, "updated": 2.0 }, + "summary": { "additions": 3, "deletions": 1, "files": 2 } + } + } + """) - @Test - fun `parseCloudSessions maps cloud session list`() { - val raw = """{ - "cliSessions": [ - {"session_id":"cloud_1","title":"Cloud One","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","version":2}, - {"session_id":"cloud_2","title":null,"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-04T00:00:00Z","version":3.5,"extra":true} - ], - "nextCursor": "cursor_2" - }""" + val result = KiloCliDataParser.parseChatEvent("session.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals("Updated title", result.session.title) + assertEquals(2, result.session.summary?.files) + } - val result = KiloCliDataParser.parseCloudSessions(raw) + @Test + fun `parseChatEvent - session diff`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] + } + """) - assertEquals(2, result.sessions.size) - assertEquals("cloud_1", result.sessions[0].id) - assertEquals("Cloud One", result.sessions[0].title) - assertEquals("2026-01-02T00:00:00Z", result.sessions[0].updatedAt) - assertEquals(2.0, result.sessions[0].version) - assertNull(result.sessions[1].title) - assertEquals("cursor_2", result.nextCursor) - } + val result = KiloCliDataParser.parseChatEvent("session.diff", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionDiffChanged) + assertEquals(1, result.diff.size) + assertEquals("src/A.kt", result.diff[0].file) + assertEquals(3, result.diff[0].additions) + } - @Test - fun `parseCloudSessions tolerates malformed response`() { - assertEquals(emptyList(), KiloCliDataParser.parseCloudSessions("not json").sessions) - assertNull(KiloCliDataParser.parseCloudSessions("{}").nextCursor) - } + @Test + fun `parseChatEvent - session diff clamps large counts`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}] + } + """) - // ================================================================ - // parseMessages - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged + assertEquals(Int.MAX_VALUE, result.diff[0].additions) + assertEquals(Int.MAX_VALUE, result.diff[0].deletions) + } - @Test - fun `parseMessages - empty array`() { - assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]")) - } + @Test + fun `parseChatEvent - todo updated`() { + val data = globalEvent(""" + "type": "todo.updated", + "properties": { + "sessionID": "ses_1", + "todos": [ + {"content": "Write tests", "status": "in_progress", "priority": "high"}, + {"content": "Review PR", "status": "pending", "priority": "medium"} + ] + } + """) - @Test - fun `parseMessages - user and assistant messages`() { - val raw = """[ - { - "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, - "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] - }, - { - "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, - "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] - } - ]""" + val result = KiloCliDataParser.parseChatEvent("todo.updated", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.TodoUpdated) + assertEquals("ses_1", result.sessionID) + assertEquals(2, result.todos.size) + assertEquals("Write tests", result.todos[0].content) + assertEquals("high", result.todos[0].priority) + } - val result = KiloCliDataParser.parseMessages(raw) - assertEquals(2, result.size) - assertEquals("user", result[0].info.role) - assertEquals("Hello", result[0].parts[0].text) - assertEquals("assistant", result[1].info.role) - assertEquals("Hi there", result[1].parts[0].text) - } + // ---- session status events ---- - @Test - fun `parseMessages - message with tool parts`() { - val raw = """[{ - "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, - "parts": [{ - "id": "p1", - "sessionID": "s1", - "messageID": "m1", - "type": "tool", - "tool": "read_file", - "state": { "status": "completed", "title": "Read file.txt" } - }] - }]""" + @Test + fun `parseChatEvent - session status idle`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { "sessionID": "ses_1", "status": {"type": "idle"} } + """) - val result = KiloCliDataParser.parseMessages(raw) - assertEquals(1, result.size) - val part = result[0].parts[0] - assertEquals("tool", part.type) - assertEquals("read_file", part.tool) - assertEquals("completed", part.state) - assertEquals("Read file.txt", part.title) - } + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("idle", result.status.type) + assertNull(result.status.attempt) + assertNull(result.status.requestID) + } - @Test - fun `parseMessages - step finish part with tokens`() { - val raw = """[{ - "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, - "parts": [{ - "id": "p1", - "sessionID": "s1", - "messageID": "m1", - "type": "step-finish", - "reason": "stop", - "cost": 0.005, - "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } } - }] - }]""" + @Test + fun `parseChatEvent - session status retry with attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000} + } + """) - val part = KiloCliDataParser.parseMessages(raw)[0].parts[0] - assertEquals("step-finish", part.type) - assertEquals("stop", part.reason) - assertEquals(0.005, part.cost) - assertEquals(100L, part.tokens?.input) - assertEquals(50L, part.tokens?.output) - assertEquals(10L, part.tokens?.reasoning) - assertEquals(20L, part.tokens?.cacheRead) - assertEquals(5L, part.tokens?.cacheWrite) - } + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("retry", result.status.type) + assertEquals("Retrying...", result.status.message) + assertEquals(2, result.status.attempt) + assertEquals(5000L, result.status.next) + } - @Test - fun `parseMessages - malformed JSON returns empty`() { - assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) - } + @Test + fun `parseChatEvent - session status clamps large attempt`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807} + } + """) - @Test - fun `parseMessages - message with tokens`() { - val raw = """[{ - "info": { - "id": "m1", "sessionID": "s1", "role": "assistant", - "time": { "created": 1.0, "completed": 2.0 }, - "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }, - "cost": 0.005 - }, - "parts": [] - }]""" + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals(Int.MAX_VALUE, result.status.attempt) + assertEquals(Long.MAX_VALUE, result.status.next) + } - val result = KiloCliDataParser.parseMessages(raw) - val info = result[0].info - assertNotNull(info.tokens) - assertEquals(100L, info.tokens?.input) - assertEquals(50L, info.tokens?.output) - assertEquals(10L, info.tokens?.reasoning) - assertEquals(20L, info.tokens?.cacheRead) - assertEquals(5L, info.tokens?.cacheWrite) - assertEquals(0.005, info.cost) - assertEquals(2.0, info.time.completed) - } + @Test + fun `parseChatEvent - session status offline with requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "offline", "message": "No network", "requestID": "req_abc"} + } + """) - // ================================================================ - // parseModelState / buildModelStateJson - // ================================================================ + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals("offline", result.status.type) + assertEquals("No network", result.status.message) + assertEquals("req_abc", result.status.requestID) + } - @Test - fun `parseModelState - parses favorites`() { - val result = KiloCliDataParser.parseModelState( - """{"favorite":[{"providerID":"kilo","modelID":"auto"},{"providerID":"openai","modelID":"gpt"}]}""", - ) + // ---- permission / question events ---- - assertEquals(listOf("kilo/auto", "openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseChatEvent - permission asked`() { + val data = globalEvent(""" + "type": "permission.asked", + "properties": { + "id": "perm_1", + "sessionID": "ses_1", + "permission": "edit", + "patterns": ["*.kt"], + "always": [], + "metadata": {"file": "src/A.kt"}, + "tool": {"messageID": "msg_1", "callID": "call_1"} + } + """) - @Test - fun `parseModelState - parses recent selections`() { - val result = KiloCliDataParser.parseModelState( - """{"recent":[{"providerID":"anthropic","modelID":"claude"},{"providerID":"openai","modelID":"gpt"}]}""", - ) + val result = KiloCliDataParser.parseChatEvent("permission.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.request.id) + assertEquals("edit", result.request.permission) + assertEquals(listOf("*.kt"), result.request.patterns) + assertEquals("src/A.kt", result.request.metadata["file"]) + assertEquals("msg_1", result.request.tool?.messageID) + } - assertEquals(listOf("anthropic/claude", "openai/gpt"), result.recent.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseChatEvent - permission replied`() { + val data = globalEvent(""" + "type": "permission.replied", + "properties": { "sessionID": "ses_1", "requestID": "perm_1" } + """) - @Test - fun `parseModelState - parses model selections and variants`() { - val result = KiloCliDataParser.parseModelState( - """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"variant":{"kilo/auto":"medium"}}""", - ) + val result = KiloCliDataParser.parseChatEvent("permission.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.PermissionReplied) + assertEquals("ses_1", result.sessionID) + assertEquals("perm_1", result.requestID) + } - assertEquals("kilo", result.model["code"]?.providerID) - assertEquals("auto", result.model["code"]?.modelID) - assertEquals("medium", result.variant["kilo/auto"]) - } + @Test + fun `parseChatEvent - question asked`() { + val data = globalEvent(""" + "type": "question.asked", + "properties": { + "id": "q_1", + "sessionID": "ses_1", + "questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}], + "tool": null + } + """) - @Test - fun `parseModelState - drops malformed favorites`() { - val result = KiloCliDataParser.parseModelState( - """{"favorite":[{"providerID":"kilo"},false,{"providerID":"openai","modelID":"gpt"}]}""", - ) + val result = KiloCliDataParser.parseChatEvent("question.asked", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionAsked) + assertEquals("ses_1", result.sessionID) + assertEquals("q_1", result.request.id) + assertEquals(1, result.request.questions.size) + assertEquals("Pick one", result.request.questions[0].question) + assertEquals("A", result.request.questions[0].options[0].label) + } - assertEquals(listOf("openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) - } + @Test + fun `parseChatEvent - question replied`() { + val data = globalEvent(""" + "type": "question.replied", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) - @Test - fun `parseModelState - malformed inputs return empty favorites`() { - for (raw in listOf("", "not-json", "[]", "42", "null")) { - assertTrue(KiloCliDataParser.parseModelState(raw).favorite.isEmpty(), raw) + val result = KiloCliDataParser.parseChatEvent("question.replied", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionReplied) + assertEquals("q_1", result.requestID) + } + + @Test + fun `parseChatEvent - question rejected`() { + val data = globalEvent(""" + "type": "question.rejected", + "properties": { "sessionID": "ses_1", "requestID": "q_1" } + """) + + val result = KiloCliDataParser.parseChatEvent("question.rejected", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.QuestionRejected) + assertEquals("q_1", result.requestID) + } + + // ---- error cases ---- + + @Test + fun `parseChatEvent - unknown type returns null`() { + val data = globalEvent(""" + "type": "some.unknown.event", + "properties": { "sessionID": "ses_1" } + """) + assertNull(KiloCliDataParser.parseChatEvent("some.unknown.event", data)) + } + + @Test + fun `parseChatEvent - malformed JSON returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", "not json")) + } + + @Test + fun `parseChatEvent - missing properties returns null`() { + assertNull(KiloCliDataParser.parseChatEvent("message.updated", """{"payload":{"type":"x"}}""")) + } + + @Test + fun `parseChatEvent - missing sessionID returns null`() { + val data = globalEvent(""" + "type": "message.updated", + "properties": { "info": { "id": "msg_1", "role": "user", "time": {} } } + """) + assertNull(KiloCliDataParser.parseChatEvent("message.updated", data)) + } + + // ---- parseSessionStatus ---- + + @Test + fun `parseSessionStatus - valid status event`() { + val data = """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_abc", result.first) + assertEquals("busy", result.second.type) + assertEquals("Running...", result.second.message) + } + + @Test + fun `parseSessionStatus - missing sessionID returns null`() { + val data = """{"status":{"type":"idle"}}""" + assertNull(KiloCliDataParser.parseSessionStatus(data)) + } + + @Test + fun `parseSessionStatus - missing status defaults to idle`() { + val data = """{"sessionID":"ses_xyz"}""" + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("idle", result.second.type) + assertNull(result.second.message) + } + + @Test + fun `parseSessionStatus - retry preserves attempt and next`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_retry", + "status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("ses_retry", result.first) + assertEquals("retry", result.second.type) + assertEquals(3, result.second.attempt) + assertEquals(10000L, result.second.next) + } + + @Test + fun `parseSessionStatus - offline preserves requestID`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_off", + "status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"} + } + """) + val result = KiloCliDataParser.parseSessionStatus(data) + assertNotNull(result) + assertEquals("req_xyz", result.second.requestID) + } + + // ---- parsePermissionRequests / parseQuestionRequests ---- + + @Test + fun `parsePermissionRequests - parses list`() { + val raw = """[ + {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} + ]""" + val result = KiloCliDataParser.parsePermissionRequests(raw) + assertEquals(1, result.size) + assertEquals("p1", result[0].id) + assertEquals("edit", result[0].permission) + } + + @Test + fun `parsePermissionRequests - empty list`() { + assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]")) + } + + @Test + fun `parseQuestionRequests - parses list`() { + val raw = """[ + {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} + ]""" + val result = KiloCliDataParser.parseQuestionRequests(raw) + assertEquals(1, result.size) + assertEquals("q1", result[0].id) } } - @Test - fun `parseModelState - drops malformed model selections and variants`() { - val result = KiloCliDataParser.parseModelState( - """{"model":{"bad":false,"ok":{"providerID":"kilo","modelID":"auto"}},"variant":{"":"low","kilo/auto":false,"openai/gpt":"high"}}""", - ) - - assertEquals(listOf("ok"), result.model.keys.toList()) - assertEquals(mapOf("openai/gpt" to "high"), result.variant) - } - - @Test - fun `buildModelStateJson - preserves unrelated keys and replaces favorites`() { - val raw = """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"recent":[{"providerID":"old","modelID":"recent"}],"variant":{"kilo/auto":"fast"},"extra":true,"favorite":[]}""" - val result = KiloCliDataParser.buildModelStateJson(raw, listOf(ModelSelectionDto("anthropic", "claude"))) - - assertTrue(result.contains("\"model\""), result) - assertTrue(result.contains("\"recent\""), result) - assertTrue(result.contains("\"variant\""), result) - assertTrue(result.contains("\"extra\""), result) - assertEquals(listOf("anthropic/claude"), KiloCliDataParser.parseModelState(result).favorite.map { "${it.providerID}/${it.modelID}" }) - } - - @Test - fun `buildModelStateJson - writes model selections and variants`() { - val raw = """{"recent":[],"extra":true}""" - val result = KiloCliDataParser.buildModelStateJson( - raw, - ModelStateDto( - model = mapOf("code" to ModelSelectionDto("kilo", "auto")), - variant = mapOf("kilo/auto" to "medium"), - recent = listOf(ModelSelectionDto("anthropic", "claude")), - ), - ) - - val state = KiloCliDataParser.parseModelState(result) - assertEquals("auto", state.model["code"]?.modelID) - assertEquals("medium", state.variant["kilo/auto"]) - assertEquals(listOf("anthropic/claude"), state.recent.map { "${it.providerID}/${it.modelID}" }) - assertTrue(result.contains("\"extra\""), result) - } - // ================================================================ - // buildPromptJson + // Group 2 — HTTP response parsing // ================================================================ - @Test - fun `buildPromptJson - text only`() { - val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello"))) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result) - } + @Nested + inner class HttpResponses { - @Test - fun `buildPromptJson - with model override`() { - val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "Hi")), - providerID = "anthropic", - modelID = "claude-4", - ) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}""")) - } + // ---- parseSession ---- - @Test - fun `buildPromptJson - with agent`() { - val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "Hi")), - agent = "ask", - ) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains(""""agent":"ask"""")) - } + @Test + fun `parseSession - full session response`() { + val raw = """{ + "id": "ses_abc", + "projectID": "proj_1", + "directory": "/tmp/project", + "parentID": null, + "title": "Test session", + "version": "1", + "time": { "created": 1000.0, "updated": 2000.0 }, + "summary": { "additions": 10, "deletions": 5, "files": 3 } + }""" - @Test - fun `buildPromptJson - with variant`() { - val prompt = PromptDto( - parts = listOf(PromptPartDto("text", "Hi")), - variant = "medium", - ) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains(""""variant":"medium"""")) - } + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_abc", result.id) + assertEquals("proj_1", result.projectID) + assertEquals("/tmp/project", result.directory) + assertNull(result.parentID) + assertEquals("Test session", result.title) + assertEquals(1000.0, result.time.created) + assertEquals(2000.0, result.time.updated) + assertNotNull(result.summary) + assertEquals(10, result.summary?.additions) + assertEquals(5, result.summary?.deletions) + assertEquals(3, result.summary?.files) + } - @Test - fun `buildPromptJson - escapes special characters`() { - val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\""))) - val result = KiloCliDataParser.buildPromptJson(prompt) - assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) - } + @Test + fun `parseSession - minimal session response`() { + val raw = """{ + "id": "ses_min", + "projectID": "proj_2", + "directory": "/tmp", + "title": "", + "version": "0", + "time": { "created": 0.0, "updated": 0.0 } + }""" - @Test - fun `buildSummarizeJson - writes provider and model`() { - val result = KiloCliDataParser.buildSummarizeJson(ModelSelectionDto("anthropic", "claude-4")) + val result = KiloCliDataParser.parseSession(raw) + assertEquals("ses_min", result.id) + assertNull(result.summary) + } - assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", result) - } + // ---- parseMessages ---- - // ================================================================ - // buildConfigPartial - // ================================================================ + @Test + fun `parseMessages - empty array`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("[]")) + } - @Test - fun `buildConfigPartial - model only`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) - assertEquals("""{"model":"anthropic/claude-4"}""", result) - } - - @Test - fun `buildConfigPartial - agent and temperature`() { - val result = KiloCliDataParser.buildConfigPartial( - ConfigUpdateDto(agent = "code", temperature = 0.7) - ) - assertTrue(result.contains(""""default_agent":"code"""")) - assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) - } - - @Test - fun `buildConfigPartial - empty update`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) - assertEquals("{}", result) - } - - @Test - fun `buildConfigPartial - temperature without agent defaults to ask`() { - val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) - assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) - } - - // ================================================================ - // parseChatEvent — permission / question events - // ================================================================ - - @Test - fun `parseChatEvent - permission asked`() { - val data = globalEvent(""" - "type": "permission.asked", - "properties": { - "id": "perm_1", - "sessionID": "ses_1", - "permission": "edit", - "patterns": ["*.kt"], - "always": [], - "metadata": {"file": "src/A.kt"}, - "tool": {"messageID": "msg_1", "callID": "call_1"} - } - """) - - val result = KiloCliDataParser.parseChatEvent("permission.asked", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PermissionAsked) - assertEquals("ses_1", result.sessionID) - assertEquals("perm_1", result.request.id) - assertEquals("edit", result.request.permission) - assertEquals(listOf("*.kt"), result.request.patterns) - assertEquals("src/A.kt", result.request.metadata["file"]) - assertEquals("msg_1", result.request.tool?.messageID) - } - - @Test - fun `parseChatEvent - permission replied`() { - val data = globalEvent(""" - "type": "permission.replied", - "properties": { "sessionID": "ses_1", "requestID": "perm_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("permission.replied", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PermissionReplied) - assertEquals("ses_1", result.sessionID) - assertEquals("perm_1", result.requestID) - } - - @Test - fun `parseChatEvent - question asked`() { - val data = globalEvent(""" - "type": "question.asked", - "properties": { - "id": "q_1", - "sessionID": "ses_1", - "questions": [{"question": "Pick one", "header": "Choice", "options": [{"label": "A", "description": "Option A"}]}], - "tool": null - } - """) - - val result = KiloCliDataParser.parseChatEvent("question.asked", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.QuestionAsked) - assertEquals("ses_1", result.sessionID) - assertEquals("q_1", result.request.id) - assertEquals(1, result.request.questions.size) - assertEquals("Pick one", result.request.questions[0].question) - assertEquals("A", result.request.questions[0].options[0].label) - } - - @Test - fun `parseChatEvent - question replied`() { - val data = globalEvent(""" - "type": "question.replied", - "properties": { "sessionID": "ses_1", "requestID": "q_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("question.replied", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.QuestionReplied) - assertEquals("q_1", result.requestID) - } - - @Test - fun `parseChatEvent - question rejected`() { - val data = globalEvent(""" - "type": "question.rejected", - "properties": { "sessionID": "ses_1", "requestID": "q_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("question.rejected", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.QuestionRejected) - assertEquals("q_1", result.requestID) - } - - // ================================================================ - // parseChatEvent — session.status with retry/offline detail - // ================================================================ - - @Test - fun `parseChatEvent - session status idle`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { "sessionID": "ses_1", "status": {"type": "idle"} } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals("idle", result.status.type) - assertNull(result.status.attempt) - assertNull(result.status.requestID) - } - - @Test - fun `parseChatEvent - session status retry with attempt and next`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_1", - "status": {"type": "retry", "message": "Retrying...", "attempt": 2, "next": 5000} - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals("retry", result.status.type) - assertEquals("Retrying...", result.status.message) - assertEquals(2, result.status.attempt) - assertEquals(5000L, result.status.next) - } - - @Test - fun `parseChatEvent - session status clamps large attempt`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_1", - "status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807} - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals(Int.MAX_VALUE, result.status.attempt) - assertEquals(Long.MAX_VALUE, result.status.next) - } - - @Test - fun `parseChatEvent - session status offline with requestID`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_1", - "status": {"type": "offline", "message": "No network", "requestID": "req_abc"} - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged - assertEquals("offline", result.status.type) - assertEquals("No network", result.status.message) - assertEquals("req_abc", result.status.requestID) - } - - // ================================================================ - // parseChatEvent — message.part.removed - // ================================================================ - - @Test - fun `parseChatEvent - message part removed`() { - val data = globalEvent(""" - "type": "message.part.removed", - "properties": { "sessionID": "ses_1", "messageID": "msg_1", "partID": "part_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("message.part.removed", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.PartRemoved) - assertEquals("ses_1", result.sessionID) - assertEquals("msg_1", result.messageID) - assertEquals("part_1", result.partID) - } - - // ================================================================ - // parseChatEvent — todo.updated - // ================================================================ - - @Test - fun `parseChatEvent - todo updated`() { - val data = globalEvent(""" - "type": "todo.updated", - "properties": { - "sessionID": "ses_1", - "todos": [ - {"content": "Write tests", "status": "in_progress", "priority": "high"}, - {"content": "Review PR", "status": "pending", "priority": "medium"} - ] - } - """) - - val result = KiloCliDataParser.parseChatEvent("todo.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.TodoUpdated) - assertEquals("ses_1", result.sessionID) - assertEquals(2, result.todos.size) - assertEquals("Write tests", result.todos[0].content) - assertEquals("high", result.todos[0].priority) - } - - // ================================================================ - // parseChatEvent — session.idle / session.compacted / session.diff - // ================================================================ - - @Test - fun `parseChatEvent - session idle`() { - val data = globalEvent(""" - "type": "session.idle", - "properties": { "sessionID": "ses_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("session.idle", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionIdle) - assertEquals("ses_1", result.sessionID) - } - - @Test - fun `parseChatEvent - session compacted`() { - val data = globalEvent(""" - "type": "session.compacted", - "properties": { "sessionID": "ses_1" } - """) - - val result = KiloCliDataParser.parseChatEvent("session.compacted", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionCompacted) - } - - @Test - fun `parseChatEvent - session updated`() { - val data = globalEvent(""" - "type": "session.updated", - "properties": { - "sessionID": "ses_1", - "info": { - "id": "ses_1", - "projectID": "proj_1", - "directory": "/tmp/project", - "title": "Updated title", - "version": "1", - "time": { "created": 1.0, "updated": 2.0 }, - "summary": { "additions": 3, "deletions": 1, "files": 2 } + @Test + fun `parseMessages - user and assistant messages`() { + val raw = """[ + { + "info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } }, + "parts": [{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "Hello" }] + }, + { + "info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } }, + "parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Hi there" }] } - } - """) + ]""" - val result = KiloCliDataParser.parseChatEvent("session.updated", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionUpdated) - assertEquals("ses_1", result.sessionID) - assertEquals("Updated title", result.session.title) - assertEquals(2, result.session.summary?.files) - } + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(2, result.size) + assertEquals("user", result[0].info.role) + assertEquals("Hello", result[0].parts[0].text) + assertEquals("assistant", result[1].info.role) + assertEquals("Hi there", result[1].parts[0].text) + } - @Test - fun `parseChatEvent - session diff`() { - val data = globalEvent(""" - "type": "session.diff", - "properties": { - "sessionID": "ses_1", - "diff": [{"file": "src/A.kt", "additions": 3, "deletions": 1, "patch": "@@ ..."}] - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.diff", data) - assertNotNull(result) - assertTrue(result is ChatEventDto.SessionDiffChanged) - assertEquals(1, result.diff.size) - assertEquals("src/A.kt", result.diff[0].file) - assertEquals(3, result.diff[0].additions) - } - - @Test - fun `parseChatEvent - session diff clamps large counts`() { - val data = globalEvent(""" - "type": "session.diff", - "properties": { - "sessionID": "ses_1", - "diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}] - } - """) - - val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged - assertEquals(Int.MAX_VALUE, result.diff[0].additions) - assertEquals(Int.MAX_VALUE, result.diff[0].deletions) - } - - // ================================================================ - // parseChatEvent — part with callID - // ================================================================ - - @Test - fun `parseChatEvent - part updated with callID`() { - val data = globalEvent(""" - "type": "message.part.updated", - "properties": { - "sessionID": "ses_1", - "part": { - "id": "part_1", - "sessionID": "ses_1", - "messageID": "msg_1", + @Test + fun `parseMessages - message with tool parts`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", "type": "tool", - "tool": "bash", - "callID": "call_abc", - "state": { "status": "running" } - } + "tool": "read_file", + "state": { "status": "completed", "title": "Read file.txt" } + }] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + assertEquals(1, result.size) + val part = result[0].parts[0] + assertEquals("tool", part.type) + assertEquals("read_file", part.tool) + assertEquals("completed", part.state) + assertEquals("Read file.txt", part.title) + } + + @Test + fun `parseMessages - step finish part with tokens`() { + val raw = """[{ + "info": { "id": "m1", "sessionID": "s1", "role": "assistant", "time": { "created": 1.0 } }, + "parts": [{ + "id": "p1", + "sessionID": "s1", + "messageID": "m1", + "type": "step-finish", + "reason": "stop", + "cost": 0.005, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } } + }] + }]""" + + val part = KiloCliDataParser.parseMessages(raw)[0].parts[0] + assertEquals("step-finish", part.type) + assertEquals("stop", part.reason) + assertEquals(0.005, part.cost) + assertEquals(100L, part.tokens?.input) + assertEquals(50L, part.tokens?.output) + assertEquals(10L, part.tokens?.reasoning) + assertEquals(20L, part.tokens?.cacheRead) + assertEquals(5L, part.tokens?.cacheWrite) + } + + @Test + fun `parseMessages - message with tokens`() { + val raw = """[{ + "info": { + "id": "m1", "sessionID": "s1", "role": "assistant", + "time": { "created": 1.0, "completed": 2.0 }, + "tokens": { "input": 100, "output": 50, "reasoning": 10, "cache": { "read": 20, "write": 5 } }, + "cost": 0.005 + }, + "parts": [] + }]""" + + val result = KiloCliDataParser.parseMessages(raw) + val info = result[0].info + assertNotNull(info.tokens) + assertEquals(100L, info.tokens?.input) + assertEquals(50L, info.tokens?.output) + assertEquals(10L, info.tokens?.reasoning) + assertEquals(20L, info.tokens?.cacheRead) + assertEquals(5L, info.tokens?.cacheWrite) + assertEquals(0.005, info.cost) + assertEquals(2.0, info.time.completed) + } + + @Test + fun `parseMessages - malformed JSON returns empty`() { + assertEquals(emptyList(), KiloCliDataParser.parseMessages("not json")) + } + + // ---- parseCloudSessions ---- + + @Test + fun `parseCloudSessions maps cloud session list`() { + val raw = """{ + "cliSessions": [ + {"session_id":"cloud_1","title":"Cloud One","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","version":2}, + {"session_id":"cloud_2","title":null,"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-04T00:00:00Z","version":3.5,"extra":true} + ], + "nextCursor": "cursor_2" + }""" + + val result = KiloCliDataParser.parseCloudSessions(raw) + + assertEquals(2, result.sessions.size) + assertEquals("cloud_1", result.sessions[0].id) + assertEquals("Cloud One", result.sessions[0].title) + assertEquals("2026-01-02T00:00:00Z", result.sessions[0].updatedAt) + assertEquals(2.0, result.sessions[0].version) + assertNull(result.sessions[1].title) + assertEquals("cursor_2", result.nextCursor) + } + + @Test + fun `parseCloudSessions tolerates malformed response`() { + assertEquals(emptyList(), KiloCliDataParser.parseCloudSessions("not json").sessions) + assertNull(KiloCliDataParser.parseCloudSessions("{}").nextCursor) + } + + // ---- parseProviders ---- + + @Test + fun `parseProviders - maps provider id, name, source, connected, and defaults`() { + val raw = """{ + "all": [{"id":"anthropic","name":"Anthropic","source":"api","env":[],"options":{},"models":{}}], + "default": {"code": "anthropic/claude-4"}, + "connected": ["anthropic"] + }""" + + val result = KiloCliDataParser.parseProviders(raw) + + assertEquals(1, result.providers.size) + assertEquals("anthropic", result.providers[0].id) + assertEquals("Anthropic", result.providers[0].name) + assertEquals("api", result.providers[0].source) + assertEquals(listOf("anthropic"), result.connected) + assertEquals(mapOf("code" to "anthropic/claude-4"), result.defaults) + } + + @Test + fun `parseProviders - maps model capabilities, limit, and recommendedIndex`() { + val raw = """{ + "all": [{ + "id": "anthropic", "name": "Anthropic", "source": "api", "env": [], "options": {}, + "models": { + "claude-4": { + "id": "claude-4", + "name": "Claude 4", + "capabilities": { + "temperature": true, "reasoning": true, + "attachment": true, "toolcall": true + }, + "limit": {"context": 200000, "input": 100000, "output": 16000}, + "status": "active", + "recommendedIndex": 2, + "variants": {"high": {}, "low": {}, "medium": {}}, + "options": {}, "headers": {} + } + } + }], + "default": {}, "connected": [] + }""" + + val provider = KiloCliDataParser.parseProviders(raw).providers[0] + val model = provider.models["claude-4"] + assertNotNull(model) + assertEquals("claude-4", model.id) + assertEquals("Claude 4", model.name) + assertTrue(model.attachment) + assertTrue(model.reasoning) + assertTrue(model.temperature) + assertTrue(model.toolCall) + assertEquals("active", model.status) + assertEquals(2.0, model.recommendedIndex) + assertEquals(200000L, model.limit?.context) + assertEquals(100000L, model.limit?.input) + assertEquals(16000L, model.limit?.output) + } + + @Test + fun `parseProviders - orders variants by effort rank then name`() { + val raw = """{ + "all": [{ + "id": "p", "name": "P", "source": "api", "env": [], "options": {}, + "models": { + "m": { + "capabilities": {}, "options": {}, "headers": {}, + "variants": {"high": {}, "low": {}, "medium": {}} + } + } + }], + "default": {}, "connected": [] + }""" + + val model = KiloCliDataParser.parseProviders(raw).providers[0].models["m"] + assertNotNull(model) + assertEquals(listOf("low", "medium", "high"), model.variants) + } + + @Test + fun `parseProviders - missing collections default to empty`() { + val result = KiloCliDataParser.parseProviders("""{"all":[],"default":{},"connected":[]}""") + assertEquals(emptyList(), result.providers) + assertEquals(emptyList(), result.connected) + assertEquals(emptyMap(), result.defaults) + } + + @Test + fun `parseProviders - model boolean capabilities default to false`() { + val raw = """{ + "all": [{ + "id": "p", "name": "P", "source": "api", "env": [], "options": {}, + "models": { "m": { "capabilities": {}, "options": {}, "headers": {} } } + }], + "default": {}, "connected": [] + }""" + + val model = KiloCliDataParser.parseProviders(raw).providers[0].models["m"] + assertNotNull(model) + assertEquals(false, model.attachment) + assertEquals(false, model.reasoning) + assertEquals(false, model.temperature) + assertEquals(false, model.toolCall) + assertNull(model.limit) + } + + @Test + fun `parseProviders - throws for malformed JSON`() { + assertFailsWith { + KiloCliDataParser.parseProviders("not json") } - """) + } - val result = KiloCliDataParser.parseChatEvent("message.part.updated", data) as ChatEventDto.PartUpdated - assertEquals("call_abc", result.part.callID) - assertEquals("bash", result.part.tool) - } - - // ================================================================ - // parseSessionStatus — full detail - // ================================================================ - - @Test - fun `parseSessionStatus - retry preserves attempt and next`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_retry", - "status": {"type": "retry", "message": "Rate limited", "attempt": 3, "next": 10000} + @Test + fun `parseProviders - throws for non-object JSON`() { + assertFailsWith { + KiloCliDataParser.parseProviders("""[1,2,3]""") } - """) - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("ses_retry", result.first) - assertEquals("retry", result.second.type) - assertEquals(3, result.second.attempt) - assertEquals(10000L, result.second.next) + } + + // ---- parseCommands ---- + + @Test + fun `parseCommands - maps name, description, source, and hints`() { + val raw = """[ + {"name":"init","description":"guided AGENTS.md setup","template":"static body","hints":["${'$'}ARGUMENTS"],"source":"command"}, + {"name":"mcp-tool","template":"","hints":["${'$'}1","${'$'}2"],"source":"mcp"} + ]""" + + val result = KiloCliDataParser.parseCommands(raw) + + assertEquals(2, result.size) + assertEquals("init", result[0].name) + assertEquals("guided AGENTS.md setup", result[0].description) + assertEquals("command", result[0].source) + assertEquals(listOf("\$ARGUMENTS"), result[0].hints) + assertEquals("mcp", result[1].source) + assertEquals(listOf("\$1", "\$2"), result[1].hints) + } + + @Test + fun `parseCommands - ignores lazy template object without crashing`() { + // Regression: CLI serializes promise-backed templates as {} which used to + // crash JetBrains startup before parsing was moved to this parser. + val raw = """[ + {"name":"local-review","description":"local review","template":{},"hints":[],"source":"command"}, + {"name":"local-review-uncommitted","description":"local review (uncommitted)","template":{},"hints":[]} + ]""" + + val result = KiloCliDataParser.parseCommands(raw) + + assertEquals(2, result.size) + assertEquals("local-review", result[0].name) + assertEquals("local review", result[0].description) + assertEquals("command", result[0].source) + assertEquals(emptyList(), result[0].hints) + assertEquals("local-review-uncommitted", result[1].name) + } + + @Test + fun `parseCommands - ignores template when it is a string`() { + val raw = """[{"name":"review","template":"do a review of ${'$'}ARGUMENTS","hints":["${'$'}ARGUMENTS"]}]""" + val result = KiloCliDataParser.parseCommands(raw) + assertEquals(1, result.size) + assertEquals("review", result[0].name) + assertEquals(listOf("\$ARGUMENTS"), result[0].hints) + } + + @Test + fun `parseCommands - missing hints defaults to empty`() { + val raw = """[{"name":"nohints","template":"x"}]""" + val result = KiloCliDataParser.parseCommands(raw) + assertEquals(emptyList(), result[0].hints) + } + + @Test + fun `parseCommands - empty array`() { + assertEquals(emptyList(), KiloCliDataParser.parseCommands("[]")) + } + + // ---- parsePathState ---- + + @Test + fun `parsePathState - extracts state from valid path response`() { + val raw = """{"home":"/home/user","state":"/home/user/.local/state/kilo","config":"/home/user/.config/kilo","worktree":"/project","directory":"/project"}""" + assertEquals("/home/user/.local/state/kilo", KiloCliDataParser.parsePathState(raw)) + } + + @Test + fun `parsePathState - returns null for missing state field`() { + assertNull(KiloCliDataParser.parsePathState("""{"home":"/home/user"}""")) + } + + @Test + fun `parsePathState - returns null for malformed JSON`() { + assertNull(KiloCliDataParser.parsePathState("not json")) + } + + @Test + fun `parsePathState - returns null for non-string state value`() { + assertNull(KiloCliDataParser.parsePathState("""{"state":42}""")) + assertNull(KiloCliDataParser.parsePathState("""{"state":null}""")) + assertNull(KiloCliDataParser.parsePathState("""{"state":{}}""")) + } } - @Test - fun `parseSessionStatus - offline preserves requestID`() { - val data = globalEvent(""" - "type": "session.status", - "properties": { - "sessionID": "ses_off", - "status": {"type": "offline", "message": "Offline", "requestID": "req_xyz"} + // ================================================================ + // Group 3 — Request / body builders and local model state + // ================================================================ + + @Nested + inner class RequestBuilders { + + // ---- buildPromptJson ---- + + @Test + fun `buildPromptJson - text only`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "Hello"))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertEquals("""{"parts":[{"type":"text","text":"Hello"}]}""", result) + } + + @Test + fun `buildPromptJson - with model override`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + providerID = "anthropic", + modelID = "claude-4", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""model":{"providerID":"anthropic","modelID":"claude-4"}""")) + } + + @Test + fun `buildPromptJson - with messageID`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + messageID = "msg_1", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""messageID":"msg_1"""")) + } + + @Test + fun `buildPromptJson - with noReply`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + noReply = true, + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result) + } + + @Test + fun `buildPromptJson - with agent`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + agent = "ask", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""agent":"ask"""")) + } + + @Test + fun `buildPromptJson - with variant`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + variant = "medium", + ) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains(""""variant":"medium"""")) + } + + @Test + fun `buildPromptJson - escapes special characters`() { + val prompt = PromptDto(parts = listOf(PromptPartDto("text", "line1\nline2\t\"quoted\""))) + val result = KiloCliDataParser.buildPromptJson(prompt) + assertTrue(result.contains("""line1\nline2\t\"quoted\"""")) + } + + // ---- buildSummarizeJson ---- + + @Test + fun `buildSummarizeJson - writes provider and model`() { + val result = KiloCliDataParser.buildSummarizeJson(ModelSelectionDto("anthropic", "claude-4")) + assertEquals("""{"providerID":"anthropic","modelID":"claude-4"}""", result) + } + + // ---- buildConfigPartial ---- + + @Test + fun `buildConfigPartial - model only`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(model = "anthropic/claude-4")) + assertEquals("""{"model":"anthropic/claude-4"}""", result) + } + + @Test + fun `buildConfigPartial - agent and temperature`() { + val result = KiloCliDataParser.buildConfigPartial( + ConfigUpdateDto(agent = "code", temperature = 0.7) + ) + assertTrue(result.contains(""""default_agent":"code"""")) + assertTrue(result.contains(""""agent":{"code":{"temperature":0.7}}""")) + } + + @Test + fun `buildConfigPartial - empty update`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto()) + assertEquals("{}", result) + } + + @Test + fun `buildConfigPartial - temperature without agent defaults to ask`() { + val result = KiloCliDataParser.buildConfigPartial(ConfigUpdateDto(temperature = 0.5)) + assertTrue(result.contains(""""agent":{"ask":{"temperature":0.5}}""")) + } + + // ---- buildPermissionReplyJson ---- + + @Test + fun `buildPermissionReplyJson - once reply`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once")) + assertEquals("""{"reply":"once"}""", result) + } + + @Test + fun `buildPermissionReplyJson - always reply with message`() { + val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved")) + assertTrue(result.contains(""""reply":"always"""")) + assertTrue(result.contains(""""message":"approved"""")) + } + + // ---- buildPermissionAlwaysRulesJson ---- + + @Test + fun `buildPermissionAlwaysRulesJson - approved list`() { + val result = KiloCliDataParser.buildPermissionAlwaysRulesJson( + PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList()) + ) + assertTrue(result.contains(""""approvedAlways":["src/**"]""")) + assertTrue(result.contains(""""deniedAlways":[]""")) + } + + // ---- buildQuestionReplyJson ---- + + @Test + fun `buildQuestionReplyJson - single question single answer`() { + val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A")))) + assertEquals("""{"answers":[["A"]]}""", result) + } + + @Test + fun `buildQuestionReplyJson - multiple questions`() { + val result = KiloCliDataParser.buildQuestionReplyJson( + QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes"))) + ) + assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result) + } + + // ---- parseModelState / buildModelStateJson ---- + + @Test + fun `parseModelState - parses favorites`() { + val result = KiloCliDataParser.parseModelState( + """{"favorite":[{"providerID":"kilo","modelID":"auto"},{"providerID":"openai","modelID":"gpt"}]}""", + ) + assertEquals(listOf("kilo/auto", "openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `parseModelState - parses recent selections`() { + val result = KiloCliDataParser.parseModelState( + """{"recent":[{"providerID":"anthropic","modelID":"claude"},{"providerID":"openai","modelID":"gpt"}]}""", + ) + assertEquals(listOf("anthropic/claude", "openai/gpt"), result.recent.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `parseModelState - parses model selections and variants`() { + val result = KiloCliDataParser.parseModelState( + """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"variant":{"kilo/auto":"medium"}}""", + ) + assertEquals("kilo", result.model["code"]?.providerID) + assertEquals("auto", result.model["code"]?.modelID) + assertEquals("medium", result.variant["kilo/auto"]) + } + + @Test + fun `parseModelState - drops malformed favorites`() { + val result = KiloCliDataParser.parseModelState( + """{"favorite":[{"providerID":"kilo"},false,{"providerID":"openai","modelID":"gpt"}]}""", + ) + assertEquals(listOf("openai/gpt"), result.favorite.map { "${it.providerID}/${it.modelID}" }) + } + + @Test + fun `parseModelState - malformed inputs return empty favorites`() { + for (raw in listOf("", "not-json", "[]", "42", "null")) { + assertTrue(KiloCliDataParser.parseModelState(raw).favorite.isEmpty(), raw) } - """) - val result = KiloCliDataParser.parseSessionStatus(data) - assertNotNull(result) - assertEquals("req_xyz", result.second.requestID) - } + } - // ================================================================ - // buildPermissionReplyJson - // ================================================================ + @Test + fun `parseModelState - drops malformed model selections and variants`() { + val result = KiloCliDataParser.parseModelState( + """{"model":{"bad":false,"ok":{"providerID":"kilo","modelID":"auto"}},"variant":{"":"low","kilo/auto":false,"openai/gpt":"high"}}""", + ) + assertEquals(listOf("ok"), result.model.keys.toList()) + assertEquals(mapOf("openai/gpt" to "high"), result.variant) + } - @Test - fun `buildPermissionReplyJson - once reply`() { - val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "once")) - assertEquals("""{"reply":"once"}""", result) - } + @Test + fun `buildModelStateJson - preserves unrelated keys and replaces favorites`() { + val raw = """{"model":{"code":{"providerID":"kilo","modelID":"auto"}},"recent":[{"providerID":"old","modelID":"recent"}],"variant":{"kilo/auto":"fast"},"extra":true,"favorite":[]}""" + val result = KiloCliDataParser.buildModelStateJson(raw, listOf(ModelSelectionDto("anthropic", "claude"))) - @Test - fun `buildPermissionReplyJson - always reply with message`() { - val result = KiloCliDataParser.buildPermissionReplyJson(PermissionReplyDto(reply = "always", message = "approved")) - assertTrue(result.contains(""""reply":"always"""")) - assertTrue(result.contains(""""message":"approved"""")) - } + assertTrue(result.contains("\"model\""), result) + assertTrue(result.contains("\"recent\""), result) + assertTrue(result.contains("\"variant\""), result) + assertTrue(result.contains("\"extra\""), result) + assertEquals(listOf("anthropic/claude"), KiloCliDataParser.parseModelState(result).favorite.map { "${it.providerID}/${it.modelID}" }) + } - // ================================================================ - // buildPermissionAlwaysRulesJson - // ================================================================ + @Test + fun `buildModelStateJson - writes model selections and variants`() { + val raw = """{"recent":[],"extra":true}""" + val result = KiloCliDataParser.buildModelStateJson( + raw, + ModelStateDto( + model = mapOf("code" to ModelSelectionDto("kilo", "auto")), + variant = mapOf("kilo/auto" to "medium"), + recent = listOf(ModelSelectionDto("anthropic", "claude")), + ), + ) - @Test - fun `buildPermissionAlwaysRulesJson - approved list`() { - val result = KiloCliDataParser.buildPermissionAlwaysRulesJson( - PermissionAlwaysRulesDto(approvedAlways = listOf("src/**"), deniedAlways = emptyList()) - ) - assertTrue(result.contains(""""approvedAlways":["src/**"]""")) - assertTrue(result.contains(""""deniedAlways":[]""")) - } - - // ================================================================ - // buildQuestionReplyJson - // ================================================================ - - @Test - fun `buildQuestionReplyJson - single question single answer`() { - val result = KiloCliDataParser.buildQuestionReplyJson(QuestionReplyDto(answers = listOf(listOf("A")))) - assertEquals("""{"answers":[["A"]]}""", result) - } - - @Test - fun `buildQuestionReplyJson - multiple questions`() { - val result = KiloCliDataParser.buildQuestionReplyJson( - QuestionReplyDto(answers = listOf(listOf("A", "B"), listOf("Yes"))) - ) - assertEquals("""{"answers":[["A","B"],["Yes"]]}""", result) - } - - // ================================================================ - // parsePermissionRequests / parseQuestionRequests - // ================================================================ - - @Test - fun `parsePermissionRequests - parses list`() { - val raw = """[ - {"id": "p1", "sessionID": "s1", "permission": "edit", "patterns": ["*.kt"], "always": [], "metadata": {}} - ]""" - val result = KiloCliDataParser.parsePermissionRequests(raw) - assertEquals(1, result.size) - assertEquals("p1", result[0].id) - assertEquals("edit", result[0].permission) - } - - @Test - fun `parsePermissionRequests - empty list`() { - assertEquals(emptyList(), KiloCliDataParser.parsePermissionRequests("[]")) - } - - @Test - fun `parseQuestionRequests - parses list`() { - val raw = """[ - {"id": "q1", "sessionID": "s1", "questions": [{"question": "pick", "header": "h", "options": []}]} - ]""" - val result = KiloCliDataParser.parseQuestionRequests(raw) - assertEquals(1, result.size) - assertEquals("q1", result[0].id) + val state = KiloCliDataParser.parseModelState(result) + assertEquals("auto", state.model["code"]?.modelID) + assertEquals("medium", state.variant["kilo/auto"]) + assertEquals(listOf("anthropic/claude"), state.recent.map { "${it.providerID}/${it.modelID}" }) + assertTrue(result.contains("\"extra\""), result) + } } // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index f40013e7ade..bdde10d818f 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -40,6 +40,16 @@ class MockCliServer : AutoCloseable { @Volatile var warningsStatus = 200 @Volatile var notificationsStatus = 200 + // Auth / OAuth responses + @Volatile var authorizeResponse = """{"url":"https://auth.kilo.ai/device","method":"code","instructions":"Open URL and enter code: TEST-1234"}""" + @Volatile var authorizeStatus = 200 + @Volatile var callbackStatus = 200 + @Volatile var authRemoveStatus = 200 + @Volatile var organizationSetStatus = 200 + @Volatile var lastAuthorizeBody: String? = null + @Volatile var lastCallbackBody: String? = null + @Volatile var lastOrganizationSetBody: String? = null + // Project-scoped REST responses @Volatile var providers = """{"all":[],"default":{},"connected":[],"failed":[]}""" @Volatile var agents = "[]" @@ -219,13 +229,28 @@ class MockCliServer : AutoCloseable { path == "/global/config" -> respond(output, configStatus, config) path.startsWith("/config/warnings") -> respond(output, warningsStatus, warnings) path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications) - path.startsWith("/kilo/profile") -> { + path.startsWith("/kilo/profile") && method == "GET" -> { if (profileStatus == 401) { respond(output, 401, """{"message":"Unauthorized"}""") } else { respond(output, profileStatus, profile) } } + path.matches(Regex("/provider/[^/]+/oauth/authorize.*")) && method == "POST" -> { + lastAuthorizeBody = body + respond(output, authorizeStatus, authorizeResponse) + } + path.matches(Regex("/provider/[^/]+/oauth/callback.*")) && method == "POST" -> { + lastCallbackBody = body + respond(output, callbackStatus, "true") + } + bare.matches(Regex("/auth/[^/]+")) && method == "DELETE" -> { + respond(output, authRemoveStatus, "true") + } + bare == "/kilo/organization" && method == "POST" -> { + lastOrganizationSetBody = body + respond(output, organizationSetStatus, "true") + } path == "/global/event" -> handleSse(output) path == "/path" -> respond(output, 200, this.path) bare == "/provider" -> respond(output, providersStatus, providers) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index 13cc7e49567..6decdb82e85 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -274,9 +274,11 @@ class KiloBackendWorkspaceTest { } // ------ Data mapping ------ + // Detailed provider/command/path parsing correctness is covered in KiloCliDataParserTest. + // These integration tests verify end-to-end data flow: server → workspace state. @Test - fun `providers response maps models correctly`() = runBlocking { + fun `providers response reaches state with expected provider and model`() = runBlocking { mock.providers = PROVIDERS_JSON val app = setup() val ws = ready(app) @@ -286,20 +288,10 @@ class KiloBackendWorkspaceTest { } val state = ws.state.value as KiloWorkspaceState.Ready - val provider = state.providers.providers[0] - assertEquals("anthropic", provider.id) - assertEquals("Anthropic", provider.name) - val model = provider.models["claude-4"] - assertNotNull(model) - assertEquals("Claude 4", model.name) - assertTrue(model.attachment) - assertTrue(model.reasoning) - assertTrue(model.toolCall) - assertEquals(2.0, model.recommendedIndex) - assertEquals(listOf("low", "medium", "high"), model.variants) - assertEquals(200000L, model.limit?.context) - assertEquals(100000L, model.limit?.input) - assertEquals(16000L, model.limit?.output) + assertEquals(1, state.providers.providers.size) + assertEquals("anthropic", state.providers.providers[0].id) + assertNotNull(state.providers.providers[0].models["claude-4"]) + assertEquals(listOf("anthropic"), state.providers.connected) } @Test diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 8b5fef85265..af2d807656c 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -46,7 +46,7 @@ val ver = if (release) checked( val notes = providers.gradleProperty("kilo.changeNotes").orElse("Release candidate build.") val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default") -val splitPort = providers.gradleProperty("kilo.splitModeServerPort").map(::port).orElse(providers.provider(::fallback)) +val splitPort = providers.gradleProperty("kilo.splitModeServerPort").orNull?.let(::port) ?: fallback() val isolated = providers.gradleProperty("kilo.dev.storage.isolated").map { it.toBoolean() }.orElse(false) val worktreeRoot = providers.gradleProperty("kilo.dev.worktree.root").orElse( providers.provider { rootProject.layout.projectDirectory.asFile.parentFile.parentFile.canonicalPath } diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 735d87eff95..c0b996948a7 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.commonmark.autolink) implementation(libs.commonmark.tables) implementation(libs.commonmark.strikethrough) + implementation(libs.zxing.core) testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 52262d1ba8e..c126b91f2cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -1,11 +1,10 @@ package ai.kilocode.client -import ai.kilocode.client.actions.HistoryAction -import ai.kilocode.client.actions.NewSessionAction import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionSidePanelManager import ai.kilocode.log.KiloLog +import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service import com.intellij.openapi.project.DumbAware @@ -65,8 +64,10 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { toolWindow.contentManager.setSelectedContent(content) manager.newSession() - ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings -> - toolWindow.setTitleActions(listOf(NewSessionAction(), HistoryAction(), settings)) + val toolbar = ActionManager.getInstance().getAction("Kilo.ToolWindowToolbar") + if (toolbar is ActionGroup) { + val actions = toolbar.getChildren(null).toList() + toolWindow.setTitleActions(actions) } } catch (e: Exception) { LOG.error("Failed to set up Kilo tool window content", e) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt new file mode 100644 index 00000000000..83cb7cd9025 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt @@ -0,0 +1,38 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.remoting.ActionRemoteBehaviorSpecification +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAwareAction +import java.util.function.Predicate + +/** + * Toolbar action that opens the Kilo User Profile settings page. + * + * Uses a predicate-based lookup so settings open correctly in JetBrains + * Remote Development where configurables may be wrapped. + */ +class ShowProfileAction : DumbAwareAction( + KiloBundle.message("action.Kilo.ShowProfile.text"), + KiloBundle.message("action.Kilo.ShowProfile.description"), + AllIcons.General.User, +), ActionRemoteBehaviorSpecification.Frontend { + + override fun actionPerformed(e: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog( + e.project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID + }, + { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, + ) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index c6e6030d0db..4918660bc25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -11,6 +12,8 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileStatusDto import ai.kilocode.log.KiloLog import com.intellij.openapi.components.Service import fleet.rpc.client.durable @@ -216,6 +219,59 @@ class KiloAppService internal constructor( _favorites.value = state.favorite } + /** Refresh the user profile and return the latest data. Null = not logged in. */ + suspend fun refreshProfile(): ProfileDto? = try { + call { refreshProfile() }.also { setProfile(it) } + } catch (e: Exception) { + LOG.warn("profile refresh failed", e) + null + } + + /** Refresh profile in fire-and-forget fashion from non-suspend context. */ + fun refreshProfileAsync() { + cs.launch { refreshProfile() } + } + + /** + * Start the Kilo device auth login flow. + * Returns [DeviceAuthDto] with the URL/code to display. + * Throws on failure. + */ + suspend fun startLogin(directory: String? = null): DeviceAuthDto = call { startLogin(directory) } + + /** + * Complete the login flow. Blocks until authentication finishes. + * Returns the user profile, or null if unavailable. + */ + suspend fun completeLogin(directory: String? = null): ProfileDto? = try { + call { completeLogin(directory) }.also { setProfile(it) } + } catch (e: Exception) { + LOG.warn("login completion failed", e) + null + } + + /** Log out and clear the user profile. */ + suspend fun logout(): Boolean = try { + call { logout() }.also { ok -> + if (ok) setProfile(null) + } + } catch (e: Exception) { + LOG.warn("logout failed", e) + false + } + + /** + * Switch active account context. + * Pass null for personal account, organization ID for org context. + * Returns the updated profile, or null if not logged in. + */ + suspend fun setOrganization(organizationId: String?): ProfileDto? = try { + call { setOrganization(organizationId) }.also { setProfile(it) } + } catch (e: Exception) { + LOG.warn("organization switch failed", e) + null + } + /** * Collect app state changes and invoke [fn] for each update. */ @@ -227,4 +283,12 @@ class KiloAppService internal constructor( } } } + + private fun setProfile(profile: ProfileDto?) { + val current = _state.value + val progress = current.progress?.copy( + profile = if (profile == null) ProfileStatusDto.NOT_LOGGED_IN else ProfileStatusDto.LOADED, + ) + _state.value = current.copy(profile = profile, progress = progress) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 612032ca90d..329fa59d787 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.ReasoningPicker import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel @@ -21,17 +22,25 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionView +import ai.kilocode.client.settings.profile.UserProfileConfigurable import ai.kilocode.log.ChatLogSummary +import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.colors.EditorColorsListener import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.Disposable +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurableWithId +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.registry.Registry +import java.util.function.Predicate import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import javax.swing.BoxLayout @@ -79,10 +88,12 @@ class SessionUi( beforeUpdate = { if (opening) false else scroll.atBottom() }, afterUpdate = { if (!opening) scroll.followBottom(it) }, loaded = ::onSessionLoaded, + openProfileAction = ::openProfileSettings, ) private lateinit var root: SessionRootPanel + private lateinit var account: SessionAccountOverlay private lateinit var sessionContent: JPanel @@ -98,6 +109,7 @@ class SessionUi( private lateinit var question: QuestionView private lateinit var permission: PermissionView + private lateinit var login: LoginRequiredView private lateinit var connection: ConnectionPanel private lateinit var prompt: PromptPanel @@ -137,6 +149,22 @@ class SessionUi( private fun buildUi() { root = SessionRootPanel() + account = SessionAccountOverlay( + select = { org -> controller.selectOrganization(org) }, + profile = { controller.openProfile() }, + ) + root.addOverlay(account) { pane, child -> + val size = child.preferredSize + val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING) + val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING) + java.awt.Rectangle( + pane.width - size.width - right, + top, + size.width, + size.height, + ) + } + sessionContent = JPanel(BorderLayout()) blankBody = JPanel(BorderLayout()).apply { @@ -153,7 +181,8 @@ class SessionUi( permission = PermissionView( reply = { id, dto -> controller.replyPermission(id, dto) }, ) - messageBody = SessionMessageListPanel(controller.model, this, question, permission) + login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() }) + messageBody = SessionMessageListPanel(controller.model, this, question, permission, login) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) @@ -234,6 +263,8 @@ class SessionUi( } is SessionControllerEvent.ConnectionChanged -> Unit + + is SessionControllerEvent.AccountOverlayChanged -> account.onEvent(event) } } @@ -340,6 +371,16 @@ class SessionUi( refresh() } + private fun openProfileSettings() { + ShowSettingsUtil.getInstance().showSettingsDialog( + project, + Predicate { cfg: Configurable -> + cfg is ConfigurableWithId && cfg.getId() == UserProfileConfigurable.ID + }, + { cfg: Configurable -> cfg.focusOn(UserProfileConfigurable.FOCUS_ACCOUNT_COMBO) }, + ) + } + override fun dispose() {} } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/PaidModelAuth.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/PaidModelAuth.kt new file mode 100644 index 00000000000..494cc4809f8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/PaidModelAuth.kt @@ -0,0 +1,32 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.MessageErrorDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +private const val PAID_MODEL_AUTH_REQUIRED = "PAID_MODEL_AUTH_REQUIRED" +private val json = Json { ignoreUnknownKeys = true } + +/** + * Returns true when [error] signals that the user must sign in to use a paid model. + * + * Conditions (all must hold): + * - error type is "APIError" + * - statusCode is 401 + * - response body contains `error.code` or `code` equal to "PAID_MODEL_AUTH_REQUIRED" + * + * Malformed or missing response body returns false rather than throwing. + */ +internal fun isPaidModelAuthRequired(error: MessageErrorDto?): Boolean { + if (error == null) return false + if (error.type != "APIError") return false + if (error.statusCode != 401) return false + val body = error.responseBody ?: return false + return runCatching { + val obj = json.parseToJsonElement(body).jsonObject + val nested = obj["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content + val top = obj["code"]?.jsonPrimitive?.content + nested == PAID_MODEL_AUTH_REQUIRED || top == PAID_MODEL_AUTH_REQUIRED + }.getOrNull() == true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 3c0da06ba82..d989efd9181 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -25,6 +25,8 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.LoadErrorDto import ai.kilocode.rpc.dto.ModelSelectionDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileStatusDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto import ai.kilocode.rpc.dto.PermissionRequestDto @@ -74,8 +76,11 @@ class SessionController( private val beforeUpdate: () -> Boolean = { false }, private val afterUpdate: (Boolean) -> Unit = {}, private val loaded: (Boolean) -> Unit = {}, + private val openProfileAction: () -> Unit = {}, ) : Disposable { + private data class OrganizationTarget(val org: String?) + companion object { private val LOG = KiloLog.create(SessionController::class.java) internal const val RECENT_LIMIT = 5 @@ -111,6 +116,12 @@ class SessionController( private var connectionState: SessionControllerEvent.ConnectionChanged? = null private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null private val connectionDelay = DelayedState(displayMs) + private var acctState: SessionControllerEvent.AccountOverlayChanged = + SessionControllerEvent.AccountOverlayChanged.Hide + private var acctAllowed = false + private var lastProfile: ProfileDto? = null + private var target: OrganizationTarget? = null + private var loginRetry: PromptDto? = null val ready: Boolean get() = model.isReady() internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession @@ -369,8 +380,12 @@ class SessionController( fire(SessionControllerEvent.AppChanged) { model.app = state model.version = app.version + if (model.state is SessionState.LoginRequired && state.profile != null) { + resumeAfterLogin() + } syncModelSelection() syncConnectionState() + refreshAccountOverlay() } } } @@ -648,7 +663,7 @@ class SessionController( tool = null // "completed" always transitions to idle. // Other reasons: don't clobber a more specific terminal state (Error, - // AwaitingPermission, AwaitingQuestion) that arrived just before close. + // AwaitingPermission, AwaitingQuestion, LoginRequired) that arrived just before close. val current = model.state val clobberOk = event.reason == "completed" || current is SessionState.Busy @@ -660,8 +675,14 @@ class SessionController( is ChatEventDto.Error -> { partType = null tool = null - val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") - model.setState(SessionState.Error(msg, event.error?.type)) + if (isPaidModelAuthRequired(event.error)) { + loginRetry = retryPrompt() + showSession() + model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) + } else { + val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") + model.setState(SessionState.Error(msg, event.error?.type)) + } } is ChatEventDto.MessageRemoved -> { @@ -699,7 +720,11 @@ class SessionController( is ChatEventDto.SessionStatusChanged -> { val state = when (event.status.type) { - "idle" -> SessionState.Idle + "idle" -> { + val current = model.state + if (current is SessionState.LoginRequired) return + SessionState.Idle + } "busy" -> { val current = model.state if (current is SessionState.Idle || current is SessionState.Error) @@ -729,6 +754,7 @@ class SessionController( if (current !is SessionState.Error && current !is SessionState.AwaitingPermission && current !is SessionState.AwaitingQuestion + && current !is SessionState.LoginRequired ) { model.setState(SessionState.Idle) } @@ -740,6 +766,48 @@ class SessionController( } } + private fun retryPrompt(): PromptDto? { + val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null + return PromptDto( + parts = emptyList(), + messageID = msg.info.id, + providerID = msg.info.providerID, + modelID = msg.info.modelID, + agent = msg.info.agent, + variant = model.variant?.takeIf { it in model.variants }, + noReply = false, + ) + } + + private fun resumeAfterLogin() { + assertEdt() + val retry = loginRetry + loginRetry = null + if (retry == null) { + model.setState(SessionState.Idle) + return + } + val id = sid + if (id == null) { + model.setState(SessionState.Idle) + return + } + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + cs.launch { + try { + sessions.prompt(id, directory, retry) + LOG.debug { "${ChatLogSummary.sid(id)} kind=login-resume dispatched=true" } + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(id)} kind=login-resume dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (disposed) return@edt + val msg = e.message ?: KiloBundle.message("session.error.prompt") + model.setState(SessionState.Error(msg)) + } + } + } + } + private fun promptDto(text: String): PromptDto { val full = model.model val sel = full?.let(::parseModel) @@ -845,6 +913,87 @@ class SessionController( else -> KiloBundle.message("session.status.considering") } + fun selectOrganization(org: String?) { + assertEdt() + val next = OrganizationTarget(org) + if (target == next) return + target = next + refreshAccountOverlay() + cs.launch { + try { + app.setOrganization(org) + } catch (e: Exception) { + LOG.warn("account switch failed org=$org message=${e.message}", e) + edt { + if (disposed) return@edt + target = null + refreshAccountOverlay() + } + } + } + } + + fun openProfile() { + assertEdt() + openProfileAction() + } + + fun dismissLoginRequired() { + assertEdt() + loginRetry = null + if (model.state is SessionState.LoginRequired) { + updateModel { model.setState(SessionState.Idle) } + } + } + + private fun accountSnapshot(): SessionControllerEvent.AccountOverlaySnapshot { + val state = model.app + val prof = state.profile + val pending = prof == null && state.progress?.profile == ProfileStatusDto.PENDING + val current = when { + prof != null -> prof + pending -> lastProfile + else -> null + } + if (prof != null) { + lastProfile = prof + if (target?.org == prof.currentOrgId) target = null + } + if (!pending && prof == null) { + lastProfile = null + target = null + } + return SessionControllerEvent.AccountOverlaySnapshot( + status = state.status, + profile = current, + transient = pending, + switching = target != null, + targetOrgId = target?.org, + ) + } + + private fun showAccountOverlay() { + acctAllowed = true + setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot())) + } + + private fun hideAccountOverlay() { + acctAllowed = false + setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Hide) + } + + private fun refreshAccountOverlay() { + if (!acctAllowed) return + setAccountOverlayState(SessionControllerEvent.AccountOverlayChanged.Show(accountSnapshot())) + } + + private fun setAccountOverlayState(event: SessionControllerEvent.AccountOverlayChanged) { + if (acctState == event) return + fire(event) { + acctState = event + } + } + fun refreshRecents(force: Boolean = false) { assertEdt() if (!canUseRecents()) return @@ -896,6 +1045,11 @@ class SessionController( setRecentSessionsState(RecentsState.Idle) } } + when (event) { + is SessionControllerEvent.ViewChanged.ShowRecents -> showAccountOverlay() + is SessionControllerEvent.ViewChanged.ShowProgress -> hideAccountOverlay() + is SessionControllerEvent.ViewChanged.ShowSession -> hideAccountOverlay() + } } private fun openLocal() { @@ -1004,6 +1158,7 @@ class SessionController( val block: () -> Unit = { if (!disposed) { viewState?.let(listener::onEvent) + listener.onEvent(acctState) connectionState?.let(listener::onEvent) } } @@ -1083,6 +1238,10 @@ class SessionController( out.add("[error]") out.add("[${state.message}]") } + is SessionState.LoginRequired -> { + out.add("[login-required]") + out.add("[${state.message}]") + } } return out.joinToString(" ") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt index ba6217af592..50f644fe327 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SessionDto /** @@ -34,6 +36,24 @@ sealed class SessionControllerEvent { } } + data class AccountOverlaySnapshot( + val status: KiloAppStatusDto, + val profile: ProfileDto?, + val transient: Boolean = false, + val switching: Boolean = false, + val targetOrgId: String? = null, + ) + + sealed class AccountOverlayChanged : SessionControllerEvent() { + data class Show(val account: AccountOverlaySnapshot) : AccountOverlayChanged() { + override fun toString() = "AccountOverlayChanged show loggedIn=${account.profile != null}" + } + + data object Hide : AccountOverlayChanged() { + override fun toString() = "AccountOverlayChanged hide" + } + } + sealed class ConnectionChanged : SessionControllerEvent() { data object Hide : ConnectionChanged() { override fun toString() = "ConnectionChanged hide" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 156411aaf88..aaa5faef924 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -18,8 +18,10 @@ sealed class SessionState { data class Error(val message: String, val kind: String? = null) : SessionState() + data class LoginRequired(val message: String) : SessionState() + fun isBusy(): Boolean = when (this) { - is Idle, is Loading, is Error -> false + is Idle, is Loading, is Error, is LoginRequired -> false else -> true } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 48c8068068d..59b13882c34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -28,6 +28,7 @@ internal class SessionScroll( companion object { private const val THRESHOLD = 32 private const val OPEN_PASSES = 12 + private const val FOLLOW_PASSES = 6 } val component = JBScrollPane(body).apply { @@ -95,10 +96,14 @@ internal class SessionScroll( return } tail = true + stable = -1 auto = true show(messages) auto = false - followPass(++seq, 2) + val id = ++seq + ApplicationManager.getApplication().invokeLater { + followPass(id, FOLLOW_PASSES) + } } fun openBottom(done: () -> Unit) { @@ -134,12 +139,16 @@ internal class SessionScroll( auto = true show(messages) auto = false - followPass(++seq, 2) + val id = ++seq + ApplicationManager.getApplication().invokeLater { + followPass(id, FOLLOW_PASSES) + } } private fun followPass(id: Int, remaining: Int) { if (id != seq || !tail) return auto = true + val prev = bottom() try { layoutScroll() scrollToBottom() @@ -147,9 +156,15 @@ internal class SessionScroll( } finally { auto = false } - if (remaining <= 0) return + if (remaining <= 0) { + stable = -1 + return + } + val next = bottom() + val left = if (next == prev && next == stable) remaining - 1 else FOLLOW_PASSES + stable = next ApplicationManager.getApplication().invokeLater { - followPass(id, remaining - 1) + followPass(id, left) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 9051105a649..704afd4b48d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -7,6 +7,7 @@ import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionView @@ -44,6 +45,7 @@ class SessionMessageListPanel( parent: Disposable, private val question: QuestionView? = null, private val permission: PermissionView? = null, + private val login: LoginRequiredView? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( @@ -248,8 +250,8 @@ class SessionMessageListPanel( } /** - * Show or hide active question/permission views based on [state]. - * Both views are always kept as children of this panel (added in [anchorFooter]), + * Show or hide active question/permission/login views based on [state]. + * All views are always kept as children of this panel (added in [anchorFooter]), * but visibility is controlled here. */ private fun syncActive(state: SessionState = model.state) { @@ -257,17 +259,26 @@ class SessionMessageListPanel( is SessionState.AwaitingQuestion -> { setHiddenQuestionTool(state.question.tool) permission?.hideView() + login?.hideView() question?.show(state.question) } is SessionState.AwaitingPermission -> { setHiddenQuestionTool(null) question?.hideView() + login?.hideView() permission?.show(state.permission) } + is SessionState.LoginRequired -> { + setHiddenQuestionTool(null) + question?.hideView() + permission?.hideView() + login?.show(state.message) + } else -> { setHiddenQuestionTool(null) question?.hideView() permission?.hideView() + login?.hideView() } } } @@ -280,19 +291,21 @@ class SessionMessageListPanel( } /** - * Re-insert [question], [permission], and [progress] as the last children + * Re-insert [question], [permission], [login], and [progress] as the last children * so active views always render after all turn views, and progress is last. * - * Both active views are added even when invisible — [SessionLayout] skips + * All active views are added even when invisible — [SessionLayout] skips * invisible children, so no extra space is consumed, and the component tree * remains stable for tests. */ private fun anchorFooter() { if (question != null) remove(question) if (permission != null) remove(permission) + if (login != null) remove(login) remove(progress) if (question != null) add(question) if (permission != null) add(permission) + if (login != null) add(login) add(progress) } @@ -317,6 +330,7 @@ class SessionMessageListPanel( for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) + login?.applyStyle(style) progress.applyStyle(style) refresh() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt new file mode 100644 index 00000000000..52bbba4eaf5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountChoice.kt @@ -0,0 +1,5 @@ +package ai.kilocode.client.session.ui.account + +internal data class AccountChoice(val org: String?, val title: String) { + override fun toString() = title +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt new file mode 100644 index 00000000000..b4b6e4c71ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/AccountPickerRenderer.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.EmptyIcon +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Component +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.ListCellRenderer +import javax.swing.SwingConstants + +internal class AccountPickerRenderer( + private val active: () -> String?, +) : JPanel(BorderLayout()), ListCellRenderer { + companion object { + val checked: javax.swing.Icon = AllIcons.Actions.Checked + val empty: javax.swing.Icon = EmptyIcon.create(checked) + } + + private val icon = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + verticalAlignment = SwingConstants.CENTER + } + private val title = JBLabel().apply { + horizontalAlignment = SwingConstants.LEFT + verticalAlignment = SwingConstants.CENTER + } + private val row = JPanel(BorderLayout(UiStyle.Gap.md(), 0)) + private val wrap = PickerRow() + + init { + UiStyle.Components.transparent(this, icon, title, row) + row.border = JBUI.Borders.empty( + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + UiStyle.Gap.md(), + UiStyle.Gap.lg(), + ) + row.add(icon, BorderLayout.WEST) + row.add(title, BorderLayout.CENTER) + wrap.setContent(row) + add(wrap, BorderLayout.CENTER) + } + + override fun getListCellRendererComponent( + list: JList, + value: AccountChoice, + index: Int, + selected: Boolean, + focused: Boolean, + ): Component { + val focus = selected || list.hasFocus() || focused + val fg = UIUtil.getListForeground(selected, focus) + background = list.background + wrap.update(list, selected, focus) + icon.icon = icon(value) + title.text = value.title + title.foreground = fg + return this + } + + internal fun icon(value: AccountChoice): javax.swing.Icon = + if (value.org == active()) checked else empty +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt new file mode 100644 index 00000000000..d30d3ca8803 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -0,0 +1,300 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.PickerButton +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.CollectionListModel +import com.intellij.ui.ListUtil +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.ScrollingUtil +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import ai.kilocode.client.settings.profile.formatBalance +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Cursor +import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Box +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.KeyStroke +import javax.swing.ListSelectionModel +import javax.swing.ScrollPaneConstants + +/** + * Compact account overlay shown in the top-right of the empty session screen. + * + * Only visible when logged in. Hidden when not logged in or no profile is available. + * Visibility is controlled entirely by [onEvent] — never set [isVisible] externally. + */ +internal class SessionAccountOverlay( + private val select: (String?) -> Unit, + private val profile: () -> Unit, +) : BorderLayoutPanel() { + + private val picker = PickerButton().apply { + isEnabled = false + text = " " + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (!isEnabled || choices.isEmpty()) return + showPopup() + } + }) + } + + private var balanceText: String? = null + + private val balance = JBLabel().apply { + isVisible = false + } + + private val profileBtn = HoverIcon().apply { + icon = AllIcons.General.User + toolTipText = KiloBundle.message("action.Kilo.ShowProfile.description") + accessibleContext.accessibleName = KiloBundle.message("action.Kilo.ShowProfile.text") + addActionListener { profile() } + } + + private val row = JPanel().apply { + layout = BoxLayout(this, BoxLayout.X_AXIS) + isOpaque = false + add(picker) + add(Box.createHorizontalStrut(UiStyle.Gap.md())) + add(balance) + add(Box.createHorizontalStrut(UiStyle.Gap.md())) + add(profileBtn) + } + + private val panel = RoundedContentPanel(UiStyle.Gap.lg(), UiStyle.Gap.lg()).apply { + addToCenter(row) + } + + private var choices: List = emptyList() + private var currentOrgId: String? = null + + init { + isOpaque = false + isVisible = false + addToCenter(panel) + } + + @RequiresEdt + fun onEvent(event: SessionControllerEvent.AccountOverlayChanged) { + var layout = false + var paint = false + when (event) { + is SessionControllerEvent.AccountOverlayChanged.Hide -> { + if (isVisible) { + isVisible = false + layout = true + paint = true + } + } + is SessionControllerEvent.AccountOverlayChanged.Show -> { + val snap = event.account + val prof = snap.profile + if (prof == null) { + if (!snap.transient && isVisible) { + isVisible = false + layout = true + paint = true + } + } else { + layout = updateLoggedIn(prof, snap.switching, snap.targetOrgId) || layout + if (!isVisible) { + isVisible = true + layout = true + } + } + } + } + if (layout) revalidate() + if (layout || paint) repaint() + } + + @RequiresEdt + private fun updateLoggedIn(prof: ai.kilocode.rpc.dto.ProfileDto, switching: Boolean, target: String?): Boolean { + var layout = false + + val orgs = prof.organizations + val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) + + orgs.map { org -> AccountChoice(org.id, org.name) } + if (next != choices) { + choices = next + layout = true + } + + if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId + + val activeId = if (switching) target else prof.currentOrgId + val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull() + val title = "${active?.title ?: " "} ▾" + if (picker.text != title) { + picker.text = title + layout = true + } + + val enabled = !switching + if (picker.isEnabled != enabled) { + picker.isEnabled = enabled + picker.repaint() + } + + val tip = if (switching) { + KiloBundle.message("profile.switchingAccount") + } else { + KiloBundle.message("session.account.switcher") + } + if (picker.toolTipText != tip) picker.toolTipText = tip + + layout = syncBalance(prof) || layout + return layout + } + + @RequiresEdt + private fun syncBalance(prof: ai.kilocode.rpc.dto.ProfileDto): Boolean { + var layout = false + val next = prof.balance?.let { formatBalance(it.balance) } + if (next == null) { + if (balance.isVisible) { + balance.isVisible = false + layout = true + } + if (balance.icon != null) { + balance.icon = null + } + if (balance.toolTipText != null) balance.toolTipText = null + balanceText = null + } else { + if (!balance.isVisible) { + balance.isVisible = true + layout = true + } + if (balanceText != next || balance.icon == null) { + balance.icon = FilledBadgeIcon( + next, + UiStyle.Colors.badgeBg(), + UiStyle.Colors.badgeFg(), + ) + layout = true + } + val tip = KiloBundle.message("session.account.balance", next) + if (balance.toolTipText != tip) balance.toolTipText = tip + balanceText = next + } + return layout + } + + @RequiresEdt + private fun showPopup() { + val bg = UiStyle.Colors.cardBg() + val model = CollectionListModel(choices) + val list = JBList(model).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + background = bg + border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + list.cellRenderer = AccountPickerRenderer { currentOrgId } + + val idx = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 + if (idx >= 0) { + list.selectedIndex = idx + ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + } + + lateinit var popup: com.intellij.openapi.ui.popup.JBPopup + + fun activate(choice: AccountChoice) { + if (choice.org != currentOrgId) select(choice.org) + popup.closeOk(null) + } + + list.addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return + val row = list.locationToIndex(e.point) + val bounds = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return + if (!bounds.contains(e.point)) return + activate(model.getElementAt(row)) + } + }) + + ListUtil.installAutoSelectOnMouseMove(list) + ScrollingUtil.installActions(list) + + list.registerKeyboardAction( + { list.selectedValue?.let(::activate) }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + list.registerKeyboardAction( + { popup.cancel() }, + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), + JComponent.WHEN_FOCUSED, + ) + + val scroll = ScrollPaneFactory.createScrollPane(list).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() + background = bg + viewport.background = bg + viewport.isOpaque = true + } + val content = RoundedContentPanel(UiStyle.Gap.sm(), UiStyle.Gap.sm()).apply { + addToCenter(scroll) + } + + popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(content, list) + .setRequestFocus(true) + .setFocusable(true) + .setCancelOnClickOutside(true) + .setCancelKeyEnabled(true) + .setCancelOnWindowDeactivation(true) + .setResizable(false) + .setMovable(false) + .createPopup() + + popup.showUnderneathOf(picker) + } + + /** + * Activate an account choice without showing the popup. + * Only calls [select] when the choice differs from [currentOrgId]. + * Used by tests and by the popup's confirm action. + */ + @RequiresEdt + internal fun activate(choice: AccountChoice) { + if (choice.org != currentOrgId) select(choice.org) + } + + internal fun loggedInVisible() = isVisible + internal fun accountTitle(): String? = picker.text?.removeSuffix(" ▾")?.ifBlank { null } + internal fun pickerEnabled() = picker.isEnabled + internal fun pickerVisible() = picker.isVisible + internal fun choiceCount() = choices.size + internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 + internal fun panelBackground() = panel.background + internal fun panelBorderColor() = UiStyle.Colors.cardBorder() + internal fun balanceVisible() = balance.isVisible + internal fun balanceIcon() = balance.icon + internal fun balanceText() = balanceText + internal fun profileIcon() = profileBtn.icon + internal fun clickProfile() = profileBtn.doClick() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index 1dc0ea9a5ca..a6c004b46fc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.model import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.ui.PickerRow +import ai.kilocode.client.ui.FilledBadgeIcon import ai.kilocode.client.ui.UiStyle import com.intellij.icons.AllIcons import com.intellij.ui.CollectionListModel @@ -12,18 +13,12 @@ import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel import com.intellij.util.ui.EmptyIcon -import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.BorderLayout -import java.awt.Component import java.awt.FlowLayout -import java.awt.Graphics -import java.awt.Graphics2D import java.awt.Point import java.awt.Rectangle -import java.awt.RenderingHints -import java.awt.font.FontRenderContext import javax.swing.Icon import javax.swing.JList import javax.swing.JPanel @@ -70,7 +65,11 @@ internal class ModelPickerRenderer( verticalAlignment = SwingConstants.CENTER } private val title = SimpleColoredComponent() - private val badge = BadgeIcon + private val badge = FilledBadgeIcon( + KiloBundle.message("model.picker.free"), + ModelText.freeBg(), + JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE), + ) private val provider = JBLabel() private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { add(title) @@ -157,33 +156,4 @@ internal class ModelPickerRenderer( internal fun badgeVisible(): Boolean = head.getComponent(1).isVisible private class BadgeLabel(icon: Icon) : JBLabel(icon) - - private object BadgeIcon : Icon { - private val text = KiloBundle.message("model.picker.free") - - override fun getIconWidth(): Int { - val font = JBFont.small() - val w = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() - return w + JBUI.scale(12) - } - - override fun getIconHeight(): Int = JBUI.scale(16) - - override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.translate(x, y) - g2.color = ModelText.freeBg() - g2.fillRoundRect(0, 0, iconWidth, iconHeight, JBUI.scale(4), JBUI.scale(4)) - g2.color = JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE) - g2.font = JBFont.small() - val fm = g2.fontMetrics - val y = (iconHeight + fm.ascent - fm.descent) / 2 - g2.drawString(text, JBUI.scale(6), y) - } finally { - g2.dispose() - } - } - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 9ba3f57bfd4..811847628f0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.RoundedContentPanel import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton import ai.kilocode.log.ChatLogSummary @@ -357,55 +358,22 @@ class PromptPanel( } } - private inner class PromptShell : BorderLayoutPanel() { - private val arc = JBValue.UIInteger("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC) + private inner class PromptShell : RoundedContentPanel( + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + ) { private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH) - init { - isOpaque = false - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) + override fun contentColor() = style.editorScheme.defaultBackground + + override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { + JBUI.CurrentTheme.Focus.focusColor() + } else { + SessionUiStyle.View.line() } - override fun updateUI() { - super.updateUI() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - ) - } + override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1) - override fun paintComponent(g: Graphics) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint( - RenderingHints.KEY_ANTIALIASING, - RenderingHints.VALUE_ANTIALIAS_ON, - ) - g2.color = style.editorScheme.defaultBackground - val size = arc.get() - g2.fillRoundRect(0, 0, width, height, size, size) - val active = UIUtil.isFocusAncestor(editor) - g2.color = if (active) { - JBUI.CurrentTheme.Focus.focusColor() - } else { - SessionUiStyle.View.line() - } - val bw = if (active) focus.get() else JBUI.scale(1) - for (idx in 0 until bw) { - val inset = idx - val w = width - inset * 2 - 1 - val h = height - inset * 2 - 1 - if (w > 0 && h > 0) { - g2.drawRoundRect(inset, inset, w, h, size, size) - } - } - } finally { - g2.dispose() - } - super.paintComponent(g) - } + override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt new file mode 100644 index 00000000000..2e087b3bf0c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanel.kt @@ -0,0 +1,177 @@ +package ai.kilocode.client.session.ui.shared + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Component +import java.awt.Dimension +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Shared rounded background panel for session inline views that follow the + * question-view visual style: a card surface with a header text area, a + * description text area, an optional component above the header, and slots + * for view-specific body and footer content. + * + * Both [ai.kilocode.client.session.views.question.QuestionView] and + * [ai.kilocode.client.session.views.LoginRequiredView] use this as their + * outer card shell so they share the same background, padding, and text + * styling without duplicating the setup. + * + * The column always contains (in order): optional top, [headerText], + * [descriptionText], optional body, optional footer. Call [setTopPanel], + * [setBody], or [setFooter] to replace those slots at any time. + */ +class BaseSessionQuestionPanel : RoundedContentPanel( + UiStyle.Gap.lg(), + UiStyle.Gap.pad(), +), SessionEditorStyleTarget { + + private var style = SessionEditorStyle.current() + + // All JBTextArea instances that need editor-font updates, paired with bold flag + private val tracked = mutableListOf>() + + // ---- header text ---- + val headerText: JBTextArea = makeText("", UiStyle.Colors.fg(), bold = true) + + // ---- description text ---- + val descriptionText: JBTextArea = makeText("", UiStyle.Colors.weak(), bold = false) + + // ---- slot fields ---- + private var top: JComponent? = null + private var body: JComponent? = null + private var footer: JComponent? = null + + // ---- inner layout ---- + private val col = JPanel().apply { + isOpaque = false + layout = BoxLayout(this, BoxLayout.Y_AXIS) + } + + init { + addToCenter(col) + rebuildCol() + } + + /** + * Optional panel rendered above the header row (e.g. summary + nav in + * [ai.kilocode.client.session.views.question.QuestionView]). When set, + * it is inserted as the first child of the column; calling with `null` + * removes a previously set component. + * + * The header/description text areas follow immediately after. + */ + @RequiresEdt + fun setTopPanel(top: JComponent?) { + this.top = top + rebuildCol() + } + + /** + * Replace the body slot that comes after the header/description. + * Pass `null` to remove the current body. + */ + @RequiresEdt + fun setBody(body: JComponent?) { + this.body = body + rebuildCol() + } + + /** + * Replace the footer slot that comes after the body. + * Pass `null` to remove the current footer. + */ + @RequiresEdt + fun setFooter(footer: JComponent?) { + this.footer = footer + rebuildCol() + } + + // ---- SessionEditorStyleTarget ---- + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + for ((area, bold) in tracked) applyFont(area, bold) + } + + // ---- contentColor override ---- + + override fun contentColor(): Color = SessionUiStyle.View.surface() + + override fun outlineColor(): Color = SessionUiStyle.View.line() + + // ---- helpers ---- + + private fun rebuildCol() { + col.removeAll() + top?.let { col.add(it) } + col.add(headerText) + col.add(descriptionText) + body?.let { col.add(it) } + footer?.let { col.add(it) } + col.revalidate() + col.repaint() + } + + private fun makeText(value: String, color: Color, bold: Boolean): JBTextArea { + val area = object : JBTextArea(value) { + override fun getPreferredSize() = withWidth(super.getPreferredSize().height) + + override fun getMaximumSize(): Dimension { + val size = preferredSize + return Dimension(Int.MAX_VALUE, size.height) + } + + private fun withWidth(fallback: Int): Dimension { + val w = availableWidth() + if (w <= 0) return Dimension(super.getPreferredSize().width, fallback) + val old = size + setSize(w, Int.MAX_VALUE) + val ps = super.getPreferredSize() + setSize(old) + return Dimension(w, ps.height) + } + + private fun availableWidth(): Int { + var node = parent + while (node != null) { + if (node.width > 0) { + val ins = node.insets + return (node.width - ins.left - ins.right).coerceAtLeast(0) + } + node = node.parent + } + return width + } + }.apply { + isEditable = false + isOpaque = false + isFocusable = false + caret.isVisible = false + caret.isSelectionVisible = false + lineWrap = true + wrapStyleWord = true + foreground = color + border = JBUI.Borders.empty() + alignmentX = Component.LEFT_ALIGNMENT + } + tracked.add(area to bold) + applyFont(area, bold) + return area + } + + private fun applyFont(area: JBTextArea, bold: Boolean) { + val font = if (bold) style.boldEditorFont else style.transcriptFont + if (area.font != font) area.font = font + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt new file mode 100644 index 00000000000..be821ffa30e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/shared/SessionQuestionButton.kt @@ -0,0 +1,41 @@ +package ai.kilocode.client.session.ui.shared + +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import javax.swing.JButton + +/** + * A [JButton] variant used inside session question/login-required panels. + * + * Primary buttons receive [DarculaButtonUI.DEFAULT_STYLE_KEY] so they use the + * platform's default-button accent. Buttons keep the standard Look-and-Feel + * border, padding, disabled state, and focus painting, while their component + * background follows the question card surface so border/focus chrome blends + * into the inline panel instead of the surrounding transcript. + */ +class SessionQuestionButton(text: String, val primary: Boolean) : JButton(text) { + + init { + if (primary) { + putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) + } + syncBackground() + } + + override fun updateUI() { + super.updateUI() + syncBackground() + } + + private fun syncBackground() { + background = SessionUiStyle.View.surface() + } +} + +/** Create a non-primary (secondary) session question button. */ +fun dismissButton(text: String, action: () -> Unit): SessionQuestionButton = + SessionQuestionButton(text, primary = false).apply { addActionListener { action() } } + +/** Create a primary (default/accent) session question button. */ +fun applyButton(text: String, action: () -> Unit): SessionQuestionButton = + SessionQuestionButton(text, primary = true).apply { addActionListener { action() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt new file mode 100644 index 00000000000..a616416843d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -0,0 +1,85 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel +import ai.kilocode.client.session.ui.shared.applyButton +import ai.kilocode.client.session.ui.shared.dismissButton +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.ui.UiStyle +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.Component +import javax.swing.JPanel + +/** + * Retained inline view shown at the bottom of the transcript when a session + * enters [ai.kilocode.client.session.model.SessionState.LoginRequired]. + * + * Mirrors the anchored placement of [PermissionView] and [question.QuestionView]: + * it stays as a stable child inside [ai.kilocode.client.session.ui.SessionMessageListPanel] + * and is toggled visible/hidden via [show]/[hideView]. + */ +class LoginRequiredView( + private val openProfile: () -> Unit, + private val dismiss: () -> Unit, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView { + + override val sessionViewKind = SessionView.Kind.Default + + private val card = BaseSessionQuestionPanel() + val openProfileButton = applyButton(KiloBundle.message("session.login.required.button")) { openProfile() } + val dismissButton = dismissButton(KiloBundle.message("session.login.required.dismiss")) { dismiss() } + + init { + isOpaque = false + isVisible = false + + card.headerText.text = KiloBundle.message("session.login.required.title") + card.headerText.alignmentX = Component.LEFT_ALIGNMENT + card.descriptionText.alignmentX = Component.LEFT_ALIGNMENT + + val footer = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) + alignmentX = Component.LEFT_ALIGNMENT + add(dismissButton, BorderLayout.WEST) + add(openProfileButton, BorderLayout.EAST) + } + + card.setFooter(footer) + + addToCenter(card) + } + + /** Make the view visible with [message] shown as the description. */ + @RequiresEdt + fun show(message: String) { + card.descriptionText.text = message + isVisible = true + refresh() + } + + /** Hide the view. */ + @RequiresEdt + fun hideView() { + if (!isVisible) return + isVisible = false + refresh() + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + card.applyStyle(style) + } + + private fun refresh() { + revalidate() + repaint() + parent?.revalidate() + parent?.repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index bae1be3e757..cf78a87ef09 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -5,14 +5,16 @@ import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView +import ai.kilocode.client.session.ui.shared.BaseSessionQuestionPanel +import ai.kilocode.client.session.ui.shared.SessionQuestionButton +import ai.kilocode.client.session.ui.shared.applyButton +import ai.kilocode.client.session.ui.shared.dismissButton import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget -import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.icons.AllIcons -import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.util.IconLoader import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel @@ -30,7 +32,6 @@ import javax.swing.AbstractButton import javax.swing.Box import javax.swing.BoxLayout import javax.swing.ButtonGroup -import javax.swing.JButton import javax.swing.JPanel /** Question tool form rendered inside the session transcript. */ @@ -48,24 +49,8 @@ class QuestionView( private var style = SessionEditorStyle.current() private val texts = mutableListOf>() - private val card = object : BorderLayoutPanel() { - override fun updateUI() { - super.updateUI() - isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.card() - } - } - private val root = JPanel().apply { - isOpaque = false - layout = BoxLayout(this, BoxLayout.Y_AXIS) - border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) - } - private val header = JPanel(BorderLayout()).apply { - isOpaque = false - border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) - alignmentX = Component.LEFT_ALIGNMENT - } + private val card = BaseSessionQuestionPanel() + private val summary = JBLabel() private val nav = JPanel().apply { isOpaque = false @@ -85,6 +70,11 @@ class QuestionView( toolTipText = KiloBundle.message("session.question.next") addActionListener { goForward() } } + private val topPanel = JPanel(BorderLayout()).apply { + isOpaque = false + border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + alignmentX = Component.LEFT_ALIGNMENT + } private val body = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.Y_AXIS) @@ -95,9 +85,7 @@ class QuestionView( border = JBUI.Borders.emptyTop(UiStyle.Gap.lg()) alignmentX = Component.LEFT_ALIGNMENT } - private val dismiss = JButton(KiloBundle.message("session.question.dismiss")).apply { - addActionListener { doReject() } - } + private val dismiss = dismissButton(KiloBundle.message("session.question.dismiss")) { doReject() } private val right = JPanel().apply { isOpaque = false layout = BoxLayout(this, BoxLayout.X_AXIS) @@ -109,14 +97,14 @@ class QuestionView( nav.add(back) nav.add(fwd) - header.add(summary, BorderLayout.WEST) - header.add(nav, BorderLayout.EAST) + topPanel.add(summary, BorderLayout.WEST) + topPanel.add(nav, BorderLayout.EAST) footer.add(dismiss, BorderLayout.WEST) footer.add(right, BorderLayout.EAST) - root.add(header) - root.add(body) - root.add(footer) - card.add(root, BorderLayout.CENTER) + + card.setTopPanel(topPanel) + card.setBody(body) + card.setFooter(footer) add(card, BorderLayout.CENTER) } @@ -147,6 +135,7 @@ class QuestionView( override fun applyStyle(style: SessionEditorStyle) { this.style = style + card.applyStyle(style) val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc } if (!changed) return refresh() @@ -156,7 +145,22 @@ class QuestionView( val q = question ?: return texts.clear() body.removeAll() - if (review(q)) addReview(q) else addContent(q.items[idx], selections[idx]) + if (review(q)) { + card.headerText.text = KiloBundle.message("session.question.review.title") + card.descriptionText.text = "" + card.descriptionText.isVisible = false + addReview(q) + } else { + val item = q.items[idx] + card.headerText.text = item.question + card.headerText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) + card.descriptionText.text = KiloBundle.message( + if (item.multiple) "session.question.hint.multi" else "session.question.hint.single" + ) + card.descriptionText.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + card.descriptionText.isVisible = true + addContent(item, selections[idx]) + } syncHeader(q) syncFooter(q) syncControls(q) @@ -174,15 +178,10 @@ class QuestionView( private fun syncFooter(q: Question) { right.removeAll() if (review(q)) { - val back = JButton(KiloBundle.message("session.question.back")).apply { - addActionListener { goBack() } - } - val submit = JButton(KiloBundle.message("session.question.submit")).apply { - putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true) - addActionListener { doReply() } - } + val back = dismissButton(KiloBundle.message("session.question.back")) { goBack() } + val submit = applyButton(KiloBundle.message("session.question.submit")) { doReply() } right.add(back) - right.add(Box.createHorizontalStrut(JBUI.scale(UiStyle.Gap.sm()))) + right.add(Box.createHorizontalStrut(UiStyle.Gap.sm())) right.add(submit) return } @@ -192,8 +191,8 @@ class QuestionView( lastItem(q) -> KiloBundle.message("session.question.review") else -> KiloBundle.message("session.question.next") } - val button = JButton(label).apply { - putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, direct(q) || lastItem(q)) + val isPrimary = direct(q) || lastItem(q) + val button = SessionQuestionButton(label, isPrimary).apply { addActionListener { when { direct(q) -> doReply() @@ -210,37 +209,19 @@ class QuestionView( back.isEnabled = idx > 0 fwd.isEnabled = idx < q.items.size && ready for (node in right.components) { - if (node is JButton && node.text != KiloBundle.message("session.question.back")) { + if (node is SessionQuestionButton && node.text != KiloBundle.message("session.question.back")) { node.isEnabled = review(q) || ready } } } private fun addContent(item: QuestionItem, set: MutableSet) { - val title = text(item.question, UiStyle.Colors.fg(), true) - title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.xs()) - title.alignmentX = Component.LEFT_ALIGNMENT - body.add(title) - - val hint = text( - KiloBundle.message(if (item.multiple) "session.question.hint.multi" else "session.question.hint.single"), - UiStyle.Colors.weak(), - ) - hint.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) - hint.alignmentX = Component.LEFT_ALIGNMENT - body.add(hint) - val opts = optionList(item, set) opts.alignmentX = Component.LEFT_ALIGNMENT body.add(opts) } private fun addReview(q: Question) { - val title = text(KiloBundle.message("session.question.review.title"), UiStyle.Colors.fg(), true) - title.border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) - title.alignmentX = Component.LEFT_ALIGNMENT - body.add(title) - for ((i, item) in q.items.withIndex()) { val row = reviewRow(item, i) row.alignmentX = Component.LEFT_ALIGNMENT diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt new file mode 100644 index 00000000000..586be860e38 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -0,0 +1,64 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.ide.DataManager +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.options.ex.Settings +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * Root settings entry under Settings -> Tools -> Kilo Code. + * + * Displays a brief description and a link to the User Profile child page. + * Child configurables are registered in XML (`kilo.jetbrains.frontend.xml`) as + * `applicationConfigurable` entries with the appropriate `parentId` — that is the + * single source of truth for the settings hierarchy. This class does NOT implement + * [com.intellij.openapi.options.SearchableConfigurable.Parent] to avoid creating a + * second `UserProfileConfigurable` instance alongside the one registered in XML. + * + * The link uses [UserProfileConfigurable.ID] to navigate via [Settings.find]/[Settings.select]. + */ +class KiloSettingsConfigurable : SearchableConfigurable { + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.kilo.displayName") + + override fun createComponent(): JComponent { + val panel = JPanel() + panel.layout = BoxLayout(panel, BoxLayout.Y_AXIS) + panel.border = JBUI.Borders.empty(8, 0, 0, 0) + + val desc = JBLabel(KiloBundle.message("settings.kilo.description")) + desc.border = JBUI.Borders.emptyBottom(12) + panel.add(desc) + + val link = ActionLink(KiloBundle.message("settings.profile.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, UserProfileConfigurable.ID) + } + link.border = JBUI.Borders.emptyBottom(4) + panel.add(link) + + return panel + } + + override fun isModified(): Boolean = false + + override fun apply() = Unit + + internal fun open(settings: Settings, id: String = UserProfileConfigurable.ID) { + settings.find(id)?.let { settings.select(it) } + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings" + } +} 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 new file mode 100644 index 00000000000..2c7c77a383c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/BalanceFormat.kt @@ -0,0 +1,8 @@ +package ai.kilocode.client.settings.profile + +import java.text.DecimalFormat + +private val FMT = DecimalFormat("\$#,##0.00") + +/** Format a USD balance value for display (e.g. `$1,234.56`). */ +internal fun formatBalance(value: Double): String = FMT.format(value) 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 new file mode 100644 index 00000000000..aa74f1c5d31 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedInProfileUi.kt @@ -0,0 +1,265 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.RoundedContentPanel +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ProfileDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.RelativeFont +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.GridBagConstraints +import java.awt.GridBagLayout +import java.awt.KeyboardFocusManager +import java.awt.event.FocusEvent +import java.awt.event.FocusListener +import javax.swing.DefaultComboBoxModel +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Retained logged-in UI. Labels, combo box, and buttons are built once and + * mutated in [update] — no component rebuilding. + */ +internal class LoggedInProfileUi( + private val dashboard: () -> Unit, + private val logout: () -> Unit, + private val organization: (String?) -> Unit, + private val refresh: () -> Unit, +) : BorderLayoutPanel() { + + companion object { + private val LOG = KiloLog.create(LoggedInProfileUi::class.java) + } + + private val nameLabel = JBLabel().also { RelativeFont.BOLD.install(it) } + private val emailLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + setCopyable(true) + } + + private val titleLabel = JBLabel(KiloBundle.message("profile.balance.title")).apply { + foreground = UiStyle.Colors.weak() + } + private val valueLabel = JBLabel().apply { + horizontalAlignment = SwingConstants.CENTER + font = UiStyle.Fonts.display() + } + private val refreshBtn = JButton(KiloBundle.message("profile.action.refresh"), AllIcons.Actions.Refresh) + .also { + it.isOpaque = false + it.isContentAreaFilled = false + it.addActionListener { + if (refreshing) return@addActionListener + setRefreshing(true) + refresh() + } + } + + private val balanceCard = RoundedContentPanel(UiStyle.Gap.pad(), UiStyle.Gap.xl()).apply { + name = "kilo.profile.balanceCard" + addToTop(titleLabel) + addToCenter(JPanel(GridBagLayout()).apply { + isOpaque = false + add(valueLabel, GridBagConstraints().apply { + gridx = 0; gridy = 0; anchor = GridBagConstraints.CENTER + }) + add(refreshBtn, GridBagConstraints().apply { + gridx = 0; gridy = 1; anchor = GridBagConstraints.CENTER + insets = JBUI.insetsTop(UiStyle.Gap.pad()) + }) + }) + } + + private val comboModel = DefaultComboBoxModel() + val combo = ComboBox(comboModel) + + val dashboardBtn = JButton(KiloBundle.message("profile.action.dashboard")) + .also { it.addActionListener { dashboard() } } + val logoutBtn = JButton(KiloBundle.message("profile.action.logout")) + .also { it.addActionListener { logout() } } + + private val actionRow = JPanel(GridBagLayout()).apply { + add(dashboardBtn, GridBagConstraints().apply { + gridx = 0; gridy = 0; anchor = GridBagConstraints.WEST + }) + add(logoutBtn, GridBagConstraints().apply { + gridx = 1; gridy = 0; anchor = GridBagConstraints.WEST + insets = JBUI.insetsLeft(UiStyle.Gap.md()) + }) + } + + private val rows: List = listOf(nameLabel, emailLabel, combo, balanceCard, actionRow) + + private val content = JPanel(GridBagLayout()).apply { + val gap = UiStyle.Gap.lg() + rows.forEachIndexed { i, comp -> + add(comp, GridBagConstraints().apply { + gridx = 0; gridy = i + weightx = 1.0 + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.WEST + insets = if (i == 0) JBUI.emptyInsets() else JBUI.insetsTop(gap) + }) + } + } + + private var applying = false + private var refreshing = false + // Stable identity cache: (orgId or null for personal) to display name. + // Reflects what is currently shown in the retained combo model. + private var comboKeys: List> = emptyList() + // The orgId that was current as of the last applied profile update. + private var currentOrgId: String? = null + + init { + combo.addFocusListener(object : FocusListener { + override fun focusGained(e: FocusEvent) = logFocus("gained", e) + override fun focusLost(e: FocusEvent) = logFocus("lost", e) + }) + combo.addActionListener { + if (applying) return@addActionListener // programmatic update — suppress RPC + val idx = combo.selectedIndex + if (idx < 0 || idx >= comboKeys.size) return@addActionListener + val orgId = comboKeys[idx].first + // currentOrgId reflects the last profile applied by applyOrganizations. + // applying=true during model/selection changes prevents re-entry here. + if (orgId == currentOrgId) return@addActionListener + organization(orgId) + } + addToTop(content) + } + + @RequiresEdt + fun preferredFocus(): JComponent = if (combo.isVisible) combo else dashboardBtn + + private fun logFocus(kind: String, e: FocusEvent) { + val edge = if (kind == "lost") "to" else "from" + val mode = if (e.isTemporary) "temporary" else "permanent" + val peer = e.oppositeComponent?.let { + "${it.javaClass.name} name=${it.name ?: "-"} showing=${it.isShowing} visible=${it.isVisible}" + } ?: "unknown" + val owner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner?.let { + "${it.javaClass.name} name=${it.name ?: "-"}" + } ?: "unknown" + LOG.info( + "org combo focus $kind [$mode] $edge=$peer owner=$owner " + + "popup=${combo.isPopupVisible} selected=${combo.selectedIndex} " + + "size=${comboModel.size} visible=${combo.isVisible} showing=${combo.isShowing}", + ) + } + + @RequiresEdt + fun update(profile: ProfileDto) { + val display = profile.name?.takeIf { it.isNotBlank() } ?: profile.email + if (nameLabel.text != display) nameLabel.text = display + + val showEmail = profile.name != null + if (emailLabel.isVisible != showEmail) emailLabel.isVisible = showEmail + if (showEmail && emailLabel.text != profile.email) emailLabel.text = profile.email + + val bal = profile.balance + var changed = false + if (bal != null) { + val balText = formatBalance(bal.balance) + if (valueLabel.text != balText) { + valueLabel.text = balText + changed = true + } + if (!balanceCard.isVisible) { + balanceCard.isVisible = true + changed = true + } + } else { + if (balanceCard.isVisible) { + balanceCard.isVisible = false + changed = true + } + } + + applyOrganizations(profile) + if (changed) syncLayout() + } + + @RequiresEdt + fun setRefreshing(refreshing: Boolean) { + if (this.refreshing == refreshing) return + this.refreshing = refreshing + val text = if (refreshing) KiloBundle.message("profile.action.refreshing") + else KiloBundle.message("profile.action.refresh") + if (refreshBtn.text != text) refreshBtn.text = text + syncLayout() + } + + @RequiresEdt + private fun syncLayout() { + balanceCard.revalidate() + content.revalidate() + revalidate() + repaint() + } + + @RequiresEdt + private fun applyOrganizations(profile: ProfileDto) { + val orgs = profile.organizations + val keys: List> = listOf(null to KiloBundle.message("profile.personalAccount")) + + orgs.map { it.id to it.name } + + val target = profile.currentOrgId + ?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) } + ?: 0 + + currentOrgId = profile.currentOrgId + + applying = true + try { + if (keys != comboKeys) { + comboKeys = keys + syncModel(keys) + } + if (combo.selectedIndex != target) combo.selectedIndex = target + } finally { + applying = false + } + + val show = orgs.isNotEmpty() + if (combo.isVisible != show) { + combo.isVisible = show + syncLayout() + } + } + + /** + * Reconcile [comboModel] with [keys] in place — never empties the model. + * + * - Trim excess elements from the tail (avoids transient empty state). + * - Update or append each position by name. + * This keeps the model always non-empty during changes, preserving popup/focus state. + */ + @RequiresEdt + private fun syncModel(keys: List>) { + if (comboModel.size == 0) { + keys.forEach { comboModel.addElement(it.second) } + return + } + // Remove excess from the end first so indices stay stable during updates below. + while (comboModel.size > keys.size) { + comboModel.removeElementAt(comboModel.size - 1) + } + keys.forEachIndexed { i, (_, name) -> + if (i >= comboModel.size) { + comboModel.addElement(name) + } else if (comboModel.getElementAt(i) != name) { + // Insert new name before the stale one, then remove stale — never leaves a gap. + comboModel.insertElementAt(name, i) + comboModel.removeElementAt(i + 1) + } + } + } +} 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 new file mode 100644 index 00000000000..f15a3b85d24 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoggedOutProfileUi.kt @@ -0,0 +1,395 @@ +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.ui.UiStyle +import ai.kilocode.rpc.dto.KiloAppStatusDto +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.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 } + +/** + * Retained logged-out UI. Internally uses a [CardLayout] to switch between + * connecting, error, device-auth, initiating, login-error, and not-logged-in states + * without rebuilding components on every state change. + */ +internal class LoggedOutProfileUi( + private val login: () -> Unit, + private val retry: () -> Unit, + private val cancel: () -> Unit, + private val browse: (String) -> Unit, +) : JPanel(BorderLayout()) { + + private val cards = JPanel(CardLayout()) + private val cardLayout = cards.layout as CardLayout + private var mode: OutMode? = null + + // -- retained buttons -- + val loginBtn = JButton(KiloBundle.message("profile.action.login")) + .also { it.addActionListener { login() } } + + private val retryBtnConnecting = JButton(KiloBundle.message("profile.action.retry")) + .also { it.addActionListener { retry() } } + + private val retryBtnError = JButton(KiloBundle.message("profile.action.retry")) + .also { it.addActionListener { retry() } } + + 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 initiatingIcon = AsyncProcessIcon("KiloInitiating").also { it.suspend() } + + private val waitIcon = AsyncProcessIcon("KiloLogin") + + private val waitLabel = JBLabel().apply { + foreground = UiStyle.Colors.weak() + } + + private val errLabel = JBLabel().apply { + foreground = UiStyle.Colors.errorLabelForeground() + 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(loginErrorCard(), OutMode.LOGIN_ERROR.name) + add(cards, BorderLayout.NORTH) + } + + // ---- card builders (called once in init) ---- + + private fun connectingCard(): JPanel { + val p = padded() + p.add(JBLabel(KiloBundle.message("profile.status.connecting")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + }, gbc(0)) + p.add(retryBtnConnecting, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + private fun appErrorCard(): JPanel { + val p = padded() + p.add(JBLabel(KiloBundle.message("profile.status.error")).apply { + foreground = UiStyle.Colors.errorLabelForeground() + horizontalAlignment = SwingConstants.CENTER + }, gbc(0)) + p.add(retryBtnError, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + private fun emptyCard(): JPanel { + val p = padded() + p.add(JBLabel(KiloBundle.message("profile.notLoggedIn")).apply { + foreground = UiStyle.Colors.weak() + horizontalAlignment = SwingConstants.CENTER + }, gbc(0)) + p.add(loginBtn, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + private fun initiatingCard(): JPanel { + val p = padded() + val row = JPanel(FlowLayout(FlowLayout.CENTER, UiStyle.Gap.sm(), 0)).apply { + isOpaque = false + add(initiatingIcon) + add(JBLabel(KiloBundle.message("profile.login.starting")).apply { + foreground = UiStyle.Colors.weak() + }) + } + p.add(row, gbc(0).centered()) + 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)) + p.add(authRetryBtn, gbc(1, UiStyle.Gap.sm()).centered()) + return p + } + + // ---- update ---- + + @RequiresEdt + fun update(status: KiloAppStatusDto, login: LoginState) { + val target = resolveMode(status, login) + + 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() + } + } + + if (target == OutMode.LOGIN_ERROR && login is LoginState.Error) { + errLabel.text = login.message + } + + if (mode != target) { + if (mode == OutMode.AUTH) { + timer.stop() + waitIcon.suspend() + lastPendingUrl = null + } + 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() + } + } + + @RequiresEdt + fun preferredFocus(): JComponent = loginBtn + + /** Stop the timer and suspend all animated icons. Safe to call multiple times. */ + @RequiresEdt + fun dispose() { + timer.stop() + waitIcon.suspend() + initiatingIcon.suspend() + lastPendingUrl = null + } + + private fun resolveMode(status: KiloAppStatusDto, login: LoginState): OutMode = when { + status == KiloAppStatusDto.DISCONNECTED || status == KiloAppStatusDto.CONNECTING -> OutMode.CONNECTING + status == KiloAppStatusDto.ERROR -> OutMode.APP_ERROR + login is LoginState.Initiating -> OutMode.INITIATING + login is LoginState.Pending -> OutMode.AUTH + login is LoginState.Error -> OutMode.LOGIN_ERROR + 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 { + border = JBUI.Borders.empty(UiStyle.Gap.pad()) + } + + 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 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/profile/LoginState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoginState.kt new file mode 100644 index 00000000000..6aaa1be1b6a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/LoginState.kt @@ -0,0 +1,10 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.rpc.dto.DeviceAuthDto + +internal sealed interface LoginState { + data object Idle : LoginState + data object Initiating : LoginState + data class Pending(val auth: DeviceAuthDto, val started: Long) : LoginState + data class Error(val message: String) : LoginState +} 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 new file mode 100644 index 00000000000..689f908e5e4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -0,0 +1,290 @@ +package ai.kilocode.client.settings.profile + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileStatusDto +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.components.service +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import java.awt.CardLayout +import javax.swing.JComponent +import javax.swing.JPanel + +internal const val DASHBOARD_URL = "https://app.kilo.ai/profile" + +internal val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + +private enum class Card { LOGGED_OUT, LOGGED_IN } + +/** + * Retained top-level profile UI component. + * + * Builds [LoggedOutProfileUi] and [LoggedInProfileUi] once and switches between them + * using a [CardLayout] — no [removeAll] or panel rebuilds on state changes. + */ +internal class ProfileUi( + profile: ProfileDto?, + status: KiloAppStatusDto, + private val cs: CoroutineScope, + private val app: KiloAppService = service(), + private val browse: (String) -> Unit = { BrowserUtil.browse(it) }, +) : JPanel(BorderLayout()) { + + private val cards = JPanel(CardLayout()) + private val cardLayout = cards.layout as CardLayout + + private val out = LoggedOutProfileUi( + login = ::start, + retry = { app.retryAsync() }, + cancel = ::cancel, + browse = browse, + ) + private val account = LoggedInProfileUi( + dashboard = { browse(DASHBOARD_URL) }, + logout = ::logout, + organization = ::organization, + refresh = ::refreshProfile, + ) + + private var prof = profile + private var status = status + private var login: LoginState = LoginState.Idle + private var attempt = 0 + private var shown: Card? = null + + init { + cards.add(out, Card.LOGGED_OUT.name) + cards.add(account, Card.LOGGED_IN.name) + add(cards, BorderLayout.NORTH) + sync() + } + + @RequiresEdt + fun preferredFocus(): JComponent = when (targetCard()) { + Card.LOGGED_IN -> account.preferredFocus() + Card.LOGGED_OUT -> out.preferredFocus() + } + + /** + * Update from a full app state snapshot. + * + * A null profile is only treated as transient (keep the logged-in card without updating + * account content) when [KiloAppStateDto.progress]`.profile` is [ProfileStatusDto.PENDING], + * meaning a switch or initial load is still in flight. Any other null (no progress, + * NOT_LOGGED_IN, etc.) clears the profile and shows the logged-out card. + */ + @RequiresEdt + fun update(state: KiloAppStateDto) { + checkEdt() + this.status = state.status + val transient = state.profile == null && state.progress?.profile == ProfileStatusDto.PENDING + when { + state.profile != null -> { + prof = state.profile + login = LoginState.Idle + } + transient -> { /* keep existing prof and account UI untouched */ } + else -> prof = null + } + sync(skipAccount = transient) + } + + /** + * Convenience overload for callers that already hold separate profile/status values + * (login flow, direct tests). Null profile clears [prof] only when there is no existing + * profile; otherwise keeps the logged-in card visible without updating account content. + * Callers that pass null always provide a state fallback (`profile ?: state.profile`), + * so this branch is not reachable in production — it exists for transient-null tests. + */ + @RequiresEdt + fun update(profile: ProfileDto?, status: KiloAppStatusDto) { + checkEdt() + this.status = status + val transient = profile == null && prof != null + if (profile != null) { + prof = profile + login = LoginState.Idle + } else if (!transient) { + prof = null + } + sync(skipAccount = transient) + } + + @RequiresEdt + private fun sync(skipAccount: Boolean = false) { + checkEdt() + val target = targetCard() + if (target == Card.LOGGED_OUT) { + out.update(status, login) + } else if (!skipAccount) { + prof?.let { account.update(it) } + } + if (shown != target) { + cardLayout.show(cards, target.name) + shown = target + revalidate() + repaint() + } + } + + private fun targetCard(): Card { + val s = status + val p = prof + // When loading/connecting and already showing the logged-in card, stay on it to + // avoid focus loss during reconnects, initial loads, and org switches. + val transientLoad = s == KiloAppStatusDto.CONNECTING || s == KiloAppStatusDto.LOADING + if (transientLoad && shown == Card.LOGGED_IN) return Card.LOGGED_IN + return when { + s == KiloAppStatusDto.DISCONNECTED || transientLoad -> Card.LOGGED_OUT + s == KiloAppStatusDto.ERROR -> Card.LOGGED_OUT + p == null -> Card.LOGGED_OUT + else -> Card.LOGGED_IN + } + } + + @RequiresEdt + private fun applyState() { + checkEdt() + update(app.state.value) + } + + /** + * Invalidate any pending login flows and dispose the logged-out UI timer. + * Called from [ai.kilocode.client.settings.profile.UserProfileConfigurable.disposeUIResources]. + */ + @RequiresEdt + fun dispose() { + attempt++ + out.dispose() + } + + private fun checkEdt() { + check(ApplicationManager.getApplication().isDispatchThread) { + "ProfileUi updates must run on EDT" + } + } + + private fun start() { + val id = ++attempt + login = LoginState.Initiating + sync() + cs.launch { + try { + val next = app.startLogin() + withContext(edt) { + if (id != attempt) return@withContext + login = LoginState.Pending(next, System.currentTimeMillis()) + sync() + browse(next.verificationUrl) + } + val profile = app.completeLogin() + val state = app.state.value + withContext(edt) { + if (id != attempt) return@withContext + login = LoginState.Idle + update(profile ?: state.profile, state.status) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + if (id != attempt) return@withContext + login = LoginState.Error(compactLoginError(e)) + sync() + } + } + } + } + + private fun cancel() { + attempt++ + login = LoginState.Idle + sync() + } + + private fun logout() { + cs.launch { + try { + val ok = app.logout() + if (!ok) return@launch + withContext(edt) { + login = LoginState.Idle + applyState() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + } + } + } + } + + private fun organization(org: String?) { + cs.launch { + try { + val profile = app.setOrganization(org) + val state = app.state.value + withContext(edt) { + update(profile ?: state.profile, state.status) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + } + } + } + } + + private fun refreshProfile() { + cs.launch { + try { + val profile = app.refreshProfile() + val state = app.state.value + withContext(edt) { + update(profile ?: state.profile, state.status) + account.setRefreshing(false) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(edt) { + applyState() + account.setRefreshing(false) + } + } + } + } +} + +private val HTML_MARKERS = listOf(" 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 + * has no persistent settings, so [isModified] always returns false. + */ +class UserProfileConfigurable : SearchableConfigurable { + + private var ui: JComponent? = null + private var scope: CoroutineScope? = null + private var watchJob: Job? = null + private var focus = false + + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.profile.displayName") + + override fun getPreferredFocusedComponent(): JComponent? = (ui as? ProfileUi)?.preferredFocus() + + override fun focusOn(label: String) { + if (label != FOCUS_ACCOUNT_COMBO) return + focus = true + val panel = ui as? ProfileUi ?: return + requestFocus(panel) + } + + override fun createComponent(): JComponent { + val cs = CoroutineScope(SupervisorJob() + Dispatchers.Default) + scope = cs + val panel = buildPanel(cs) + ui = panel + startWatching(cs, panel) + if (focus) requestFocus(panel) + return panel + } + + private fun requestFocus(panel: ProfileUi) { + val app = ApplicationManager.getApplication() + app.invokeLater({ + app.invokeLater({ + val target = panel.preferredFocus() + if (target.isShowing) IdeFocusManager.getGlobalInstance().requestFocus(target, true) + }, ModalityState.any()) + }, ModalityState.any()) + } + + private fun buildPanel(cs: CoroutineScope): ProfileUi { + val app = service() + return ProfileUi(app.state.value.profile, app.state.value.status, cs) + } + + private fun startWatching(cs: CoroutineScope, panel: ProfileUi) { + val app = service() + watchJob = cs.launch { + app.state.collect { state -> + withContext(edt) { + panel.update(state) + } + } + } + cs.launch { + app.connect() + } + } + + override fun isModified(): Boolean = false + + override fun apply() = Unit + + override fun reset() = Unit + + override fun disposeUIResources() { + // Dispose UI first to invalidate pending login attempts before scope cancellation. + // Capturing local refs before nulling fields so the EDT callback is self-contained. + val panel = ui as? ProfileUi + val job = watchJob + val cs = scope + ui = null + watchJob = null + scope = null + + val app = ApplicationManager.getApplication() + if (panel != null) { + if (app.isDispatchThread) { + panel.dispose() + job?.cancel() + cs?.cancel() + } else { + // Schedule on EDT so dispose runs before scope cancel, as the plan requires. + app.invokeLater({ + panel.dispose() + job?.cancel() + cs?.cancel() + }, ModalityState.any()) + } + } else { + job?.cancel() + cs?.cancel() + } + } + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.profile" + const val FOCUS_ACCOUNT_COMBO = "kilo.profile.account.combo" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt new file mode 100644 index 00000000000..7c740167225 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/FilledBadgeIcon.kt @@ -0,0 +1,42 @@ +package ai.kilocode.client.ui + +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Color +import java.awt.Component +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.font.FontRenderContext +import javax.swing.Icon + +internal class FilledBadgeIcon( + private val text: String, + private val bg: Color, + private val fg: Color, +) : Icon { + override fun getIconWidth(): Int { + val font = JBFont.small() + val width = font.getStringBounds(text, FontRenderContext(null, true, true)).width.toInt() + return width + UiStyle.Gap.lg() * 2 + } + + override fun getIconHeight() = JBUI.scale(16) + + override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.translate(x, y) + g2.color = bg + g2.fillRoundRect(0, 0, iconWidth, iconHeight, iconHeight, iconHeight) + g2.color = fg + g2.font = JBFont.small() + val fm = g2.fontMetrics + val base = (iconHeight + fm.ascent - fm.descent) / 2 + g2.drawString(text, UiStyle.Gap.lg(), base) + } finally { + g2.dispose() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt index 6a08c5bb9ce..86b1b6763ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt @@ -1,22 +1,19 @@ package ai.kilocode.client.ui -import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI -import com.intellij.util.ui.UIUtil import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints import java.awt.event.MouseAdapter import java.awt.event.MouseEvent -import javax.swing.UIManager open class PickerButton : JBLabel() { private var over = false init { border = pickerBorder() - background = picker() + background = UiStyle.Colors.picker() // The custom rounded fill needs parent background around the corners. isOpaque = false addMouseListener(object : MouseAdapter() { @@ -33,14 +30,14 @@ open class PickerButton : JBLabel() { override fun updateUI() { super.updateUI() border = pickerBorder() - background = picker() + background = UiStyle.Colors.picker() } override fun paintComponent(g: Graphics) { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else picker() + g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else UiStyle.Colors.picker() val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) g2.fillRoundRect(0, 0, width, height, arc, arc) } finally { @@ -55,11 +52,5 @@ open class PickerButton : JBLabel() { repaint() } - private fun picker() = JBColor.lazy { - UIManager.getColor("ComboBoxButton.background") - ?: UIManager.getColor("ComboBox.nonEditableBackground") - ?: UIUtil.getPanelBackground() - } - private fun pickerBorder() = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.lg()) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt new file mode 100644 index 00000000000..ae1d5dde543 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/RoundedContentPanel.kt @@ -0,0 +1,61 @@ +package ai.kilocode.client.ui + +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel +import java.awt.Color +import java.awt.Graphics +import java.awt.Graphics2D +import java.awt.RenderingHints + +open class RoundedContentPanel( + top: Int, + left: Int, + bottom: Int = top, + right: Int = left, +) : BorderLayoutPanel() { + + init { + isOpaque = false + background = contentColor() + border = JBUI.Borders.empty(top, left, bottom, right) + } + + override fun updateUI() { + super.updateUI() + isOpaque = false + background = contentColor() + } + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON, + ) + val arc = cornerArc() + g2.color = contentColor() + g2.fillRoundRect(0, 0, width, height, arc, arc) + val line = outlineColor() + if (line != null) { + g2.color = line + for (idx in 0 until outlineWidth()) { + val w = width - idx * 2 - 1 + val h = height - idx * 2 - 1 + if (w > 0 && h > 0) g2.drawRoundRect(idx, idx, w, h, arc, arc) + } + } + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + protected open fun contentColor(): Color = UiStyle.Colors.cardBg() + + protected open fun outlineColor(): Color? = UiStyle.Colors.cardBorder() + + protected open fun outlineWidth(): Int = JBUI.scale(1) + + protected open fun cornerArc(): Int = UiStyle.Arc.component() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 850ab9f84dd..4d5f3265f7a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.ui import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.ui.JBColor +import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import java.awt.Color @@ -15,13 +16,21 @@ object UiStyle { object Gap { fun xs() = JBUI.scale(2) + fun sm() = JBUI.scale(4) + fun md() = JBUI.scale(6) fun lg() = JBUI.scale(8) - fun sm() = JBUI.scale(4) - fun pad() = JBUI.scale(12) + + fun xl() = JBUI.scale(16) + } + + /** Theme-aware component geometry tokens. */ + object Arc { + /** Standard component corner arc, matching the platform's `Component.arc` key. */ + fun component() = com.intellij.util.ui.JBValue.UIInteger("Component.arc", 8).get() } /** Theme-aware colors and color math used by multiple UI surfaces. */ @@ -35,6 +44,45 @@ object UiStyle { /** Uses the editor background so chat cards feel native beside editor content. */ fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } + /** + * Card surface background: follows the active theme's text-field/input surface. + * Uses [UIUtil.getTextFieldBackground] as the semantic platform surface color for + * contained panels. Falls back to the panel background when unavailable. + */ + fun cardBg(): Color = JBColor.lazy { + UIManager.getColor("TextField.background") ?: UIUtil.getPanelBackground() + } + + /** Standard picker/combobox surface, contrasted against the default panel background by the active theme. */ + fun picker(): Color = JBColor.lazy { + UIManager.getColor("ComboBoxButton.background") + ?: UIManager.getColor("ComboBox.nonEditableBackground") + ?: UIUtil.getPanelBackground() + } + + /** Filled badge surface using platform badge/info colors with a soft theme-derived fallback. */ + fun badgeBg(): Color = JBColor.lazy { + UIManager.getColor("Badge.background") + ?: UIManager.getColor("Label.infoBackground") + ?: blend(cardBg(), fg(), 0.16f) + } + + /** Filled badge text color paired with [badgeBg]. */ + fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground()) + + /** Card border color shared across profile cards. */ + fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border()) + + /** + * Floating panel background: white in light themes, black in dark themes. + * Used for account switcher popup panels and any overlay panels that need + * a high-contrast base distinct from the standard editor/sidebar background. + */ + fun floatingPanel(): Color = JBColor.namedColor( + "Kilo.FloatingPanel.background", + JBColor(java.awt.Color.WHITE, java.awt.Color.BLACK), + ) + fun errorLabelForeground(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground()) fun warningLabelForeground(): Color = JBColor.lazy { @@ -67,6 +115,23 @@ object UiStyle { (color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) >= 128 } + /** + * Platform typography tokens for use throughout the plugin. + * + * Use these instead of [java.awt.Font.deriveFont] with manual size multipliers. + * All values delegate to [JBFont] helpers which scale with the platform default font. + */ + object Fonts { + /** Large display value, e.g. account balance. Maps to [JBFont.h1] bold. */ + fun display(): JBFont = JBFont.h1().asBold() + + /** Page/section heading, e.g. login card title. Maps to [JBFont.h3] bold. */ + fun heading(): JBFont = JBFont.h3().asBold() + + /** Prominent short content, e.g. device auth code. Maps to [JBFont.h2] bold. */ + fun large(): JBFont = JBFont.h2().asBold() + } + /** Small component helpers that keep repeated Swing setup in one place. */ object Components { fun transparent(vararg components: JComponent) { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index dedf3ee3045..6557629278c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -12,6 +12,20 @@ icon="/icons/kilo.svg" factoryClass="ai.kilocode.client.KiloToolWindowFactory"/> + + + + + + + + + + + + + + + + = emptyList(), + currentOrgId: String? = null, + ) = ProfileDto(email = email, name = name, organizations = orgs, balance = balance, currentOrgId = currentOrgId) + + // ------ refreshProfile ------ + + fun `test refreshProfile updates app state profile on success`() = runBlocking(Dispatchers.Default) { + rpc.fakeProfile = profile() + val result = app.refreshProfile() + assertNotNull(result) + assertEquals("alice@test.com", result!!.email) + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + fun `test refreshProfile returns null and leaves existing state on exception`() = runBlocking(Dispatchers.Default) { + val existing = profile(email = "existing@test.com") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = existing) + rpc.refreshError = RuntimeException("refresh failed") + val result = app.refreshProfile() + assertNull(result) + assertEquals("existing@test.com", app.state.value.profile?.email) + } + + // ------ completeLogin ------ + + fun `test completeLogin updates app state profile on success`() = runBlocking(Dispatchers.Default) { + rpc.fakeProfile = profile() + val result = app.completeLogin("/my/dir") + assertNotNull(result) + assertEquals("alice@test.com", result!!.email) + assertEquals("alice@test.com", app.state.value.profile?.email) + assertEquals(listOf("/my/dir"), rpc.completeDirectories) + } + + fun `test completeLogin returns null on exception without clearing previous profile`() = runBlocking(Dispatchers.Default) { + val existing = profile(email = "existing@test.com") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = existing) + rpc.completeError = RuntimeException("complete failed") + val result = app.completeLogin("/dir") + assertNull(result) + assertEquals("existing@test.com", app.state.value.profile?.email) + } + + // ------ logout ------ + + fun `test logout clears profile when rpc returns true`() = runBlocking(Dispatchers.Default) { + val prof = profile() + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + rpc.fakeProfile = prof + rpc.logoutResult = true + val ok = app.logout() + assertTrue(ok) + assertNull(app.state.value.profile) + } + + fun `test logout does not clear profile when rpc returns false`() = runBlocking(Dispatchers.Default) { + val prof = profile() + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + rpc.logoutResult = false + val ok = app.logout() + assertFalse(ok) + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + fun `test logout returns false on exception`() = runBlocking(Dispatchers.Default) { + val prof = profile() + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + rpc.logoutError = RuntimeException("logout failed") + val ok = app.logout() + assertFalse(ok) + // Profile should be unchanged since logout threw + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + // ------ setOrganization ------ + + fun `test setOrganization updates profile on success for org id`() = runBlocking(Dispatchers.Default) { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = profile(orgs = orgs) + rpc.fakeProfile = personal + val org = personal.copy(currentOrgId = "org_1") + rpc.orgProfiles["org_1"] = org + val result = app.setOrganization("org_1") + assertNotNull(result) + assertEquals("org_1", result!!.currentOrgId) + assertEquals(listOf("org_1"), rpc.orgSelections) + assertEquals("org_1", app.state.value.profile?.currentOrgId) + } + + fun `test setOrganization updates profile for personal null selection`() = runBlocking(Dispatchers.Default) { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val org = profile(orgs = orgs, currentOrgId = "org_1") + rpc.fakeProfile = org + val personal = profile(orgs = orgs, currentOrgId = null) + rpc.orgProfiles[null] = personal + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = org) + val result = app.setOrganization(null) + assertNotNull(result) + assertNull(result!!.currentOrgId) + assertEquals(listOf(null), rpc.orgSelections) + } + + fun `test setOrganization returns null on exception without changing profile`() = runBlocking(Dispatchers.Default) { + val existing = profile(email = "alice@test.com") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = existing) + rpc.organizationError = RuntimeException("org failed") + val result = app.setOrganization("org_1") + assertNull(result) + assertEquals("alice@test.com", app.state.value.profile?.email) + } + + // ------ startLogin / completeLogin directory forwarding ------ + + fun `test startLogin forwards directory`() = runBlocking(Dispatchers.Default) { + app.startLogin("/workspace") + assertEquals(listOf("/workspace"), rpc.startDirectories) + } + + fun `test completeLogin forwards directory`() = runBlocking(Dispatchers.Default) { + rpc.fakeProfile = profile() + app.completeLogin("/workspace") + assertEquals(listOf("/workspace"), rpc.completeDirectories) + } + + fun `test startLogin with null directory is forwarded`() = runBlocking(Dispatchers.Default) { + app.startLogin(null) + assertEquals(listOf(null), rpc.startDirectories) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 337be345589..fb1dac9898d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -2,8 +2,13 @@ package ai.kilocode.client.session import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.ToolRefDto import com.intellij.util.ui.JBUI import kotlinx.coroutines.CompletableDeferred @@ -328,4 +333,79 @@ class SessionScrollTest : SessionUiTestBase() { assertSame(scrollComponent(), scrollView()?.parent?.parent) assertFalse(scrollView() is SessionMessageListPanel) } + + // ------ question/login-required autoscroll ------ + + fun `test question appearing at bottom keeps scroll at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.QuestionAsked("ses_test", question("q_at_bottom"))) + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test question appearing while user is in middle preserves scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.QuestionAsked("ses_test", question("q_middle"))) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test login required appearing at bottom keeps scroll at bottom`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body))) + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test login required appearing while user is in middle preserves scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body))) + drainScroll() + + assertEquals(value, bar.value) + assertTrue(jumpButton().isVisible) + } + + // ------ helpers ------ + + private fun question(id: String) = QuestionRequestDto( + id = id, + sessionID = "ses_test", + questions = listOf( + QuestionInfoDto( + question = "Pick one", + header = "Choice", + options = listOf(QuestionOptionDto("A", "Option A")), + multiple = false, + custom = true, + ), + ), + tool = ToolRefDto("msg1", "call1"), + ) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 1e8beed18a9..01249a40d3c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -11,10 +11,17 @@ import ai.kilocode.client.session.ui.ConnectionPanel import ai.kilocode.client.session.ui.EmptySessionPanel import ai.kilocode.client.session.ui.LoadingPanel import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionMessageListPanel import ai.kilocode.client.session.ui.SessionRootPanel import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileDto +import com.intellij.util.ui.JBUI import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -42,7 +49,7 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(root.content, stack.parent) assertSame(stack, connection.parent) - assertEquals(1, root.overlay.componentCount) + assertTrue(root.overlay.components.any { it is SessionAccountOverlay }) assertEquals(listOf(connection, prompt), stack.components.toList()) } @@ -349,4 +356,79 @@ class SessionUiLayoutTest : SessionUiTestBase() { meta = PermissionMeta(raw = emptyMap()), ) ) + + // --- account overlay layout tests --- + + fun `test account overlay is registered in root overlay layer`() { + val root = find(ui) + val overlay = find(ui) + + assertSame(root.overlay, overlay.parent) + } + + fun `test account overlay hidden before recents complete`() { + rpc.recentGate = kotlinx.coroutines.CompletableDeferred() + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + + settleShort(100) + + val overlay = find(ui) + assertFalse(overlay.isVisible) + + rpc.recentGate!!.complete(Unit) + } + + fun `test account overlay shows after recents complete`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + + settle() + + val overlay = find(ui) + assertTrue(overlay.isVisible) + } + + fun `test account overlay hides after first prompt`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + settle() + + val overlay = find(ui) + assertTrue(overlay.isVisible) + + com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait { + controller().prompt("hello") + } + settle() + + assertFalse(overlay.isVisible) + } + + fun `test explicit session does not show overlay`() { + ui = newUi(id = "ses_test") + settle() + + val overlay = find(ui) + assertFalse(overlay.isVisible) + } + + fun `test account overlay uses prompt panel top and right insets`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = ProfileDto(email = "user@example.com")) + rpc.recent.add(session("ses_1")) + ui = newUi(displayMs = 1_000) + settle() + layout() + + val root = find(ui) + val overlay = find(ui) + val top = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING) + val right = JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING) + + assertTrue(overlay.isVisible) + assertEquals(top, overlay.y) + assertEquals(root.overlay.width - overlay.width - right, overlay.x) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 13591100a75..289094a1fe2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -43,6 +43,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { protected lateinit var app: KiloAppService protected lateinit var workspaces: KiloWorkspaceService protected lateinit var rpc: FakeSessionRpcApi + protected lateinit var appRpc: FakeAppRpcApi protected lateinit var workspace: Workspace protected lateinit var ui: SessionUi @@ -51,7 +52,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { scope = CoroutineScope(SupervisorJob()) rpc = FakeSessionRpcApi() - val appRpc = FakeAppRpcApi().also { + appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } val workspaceRpc = FakeWorkspaceRpcApi().also { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index 5d8d5350a81..baefa04e2be 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -35,6 +35,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { // ViewChanged progress fires immediately on controller construction (step 3 of plan). // ViewChanged session fires after non-empty history is loaded. assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress @@ -62,6 +63,7 @@ class HistoryLoadingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.isEmpty()) assertModelEvents("HistoryLoaded", modelEvents) assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt index fa0e118ba2c..682a61fbd47 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt @@ -24,6 +24,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { flush() assertControllerEvents(""" + AccountOverlayChanged hide ViewChanged session AppChanged WorkspaceChanged @@ -47,6 +48,7 @@ class ListenerLifecycleTest : SessionControllerTestBase() { assertEquals(events1, events2) assertControllerEvents(""" + AccountOverlayChanged hide ViewChanged session AppChanged WorkspaceChanged diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PaidModelAuthTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PaidModelAuthTest.kt new file mode 100644 index 00000000000..d7151c71d34 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PaidModelAuthTest.kt @@ -0,0 +1,87 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.MessageErrorDto +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pure unit tests for [isPaidModelAuthRequired]. + * No IntelliJ platform setup needed — the function is entirely pure. + */ +class PaidModelAuthTest { + + private fun error( + type: String = "APIError", + statusCode: Int? = 401, + responseBody: String? = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""", + ) = MessageErrorDto(type = type, statusCode = statusCode, responseBody = responseBody) + + @Test + fun `null error returns false`() { + assertFalse(isPaidModelAuthRequired(null)) + } + + @Test + fun `wrong type returns false`() { + assertFalse(isPaidModelAuthRequired(error(type = "NetworkError"))) + } + + @Test + fun `missing status code returns false`() { + assertFalse(isPaidModelAuthRequired(error(statusCode = null))) + } + + @Test + fun `wrong status code returns false`() { + assertFalse(isPaidModelAuthRequired(error(statusCode = 403))) + } + + @Test + fun `missing response body returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = null))) + } + + @Test + fun `malformed response body returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = "not json {"))) + } + + @Test + fun `nested error code returns true`() { + assertTrue(isPaidModelAuthRequired(error(responseBody = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}"""))) + } + + @Test + fun `top level code returns true`() { + assertTrue(isPaidModelAuthRequired(error(responseBody = """{"code":"PAID_MODEL_AUTH_REQUIRED"}"""))) + } + + @Test + fun `unknown code returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = """{"error":{"code":"SOME_OTHER_ERROR"}}"""))) + } + + @Test + fun `response body with extra unknown fields still returns true`() { + assertTrue( + isPaidModelAuthRequired( + error(responseBody = """{"requestId":"abc","error":{"code":"PAID_MODEL_AUTH_REQUIRED","message":"Login required"}}"""), + ), + ) + } + + @Test + fun `empty json object returns false`() { + assertFalse(isPaidModelAuthRequired(error(responseBody = "{}"))) + } + + @Test + fun `nested code does not match wrong value`() { + assertFalse( + isPaidModelAuthRequired( + error(responseBody = """{"error":{"code":"UNAUTHORIZED"}}"""), + ), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 249d51e7bc2..51bd2bf2e4e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -248,6 +248,13 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { ApplicationManager.getApplication().invokeAndWait(block) } + protected fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + /** Emit a chat event into the fake RPC flow. */ protected fun emit(event: ChatEventDto, flush: Boolean = true) { runBlocking { rpc.events.emit(event) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index bff862e380d..841d2cc0963 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -2,7 +2,14 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SessionStatusDto class TurnLifecycleTest : SessionControllerTestBase() { @@ -147,6 +154,226 @@ class TurnLifecycleTest : SessionControllerTestBase() { ) } + fun `test paid model auth error enters login required state`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + + assertTrue(m.model.state is SessionState.LoginRequired) + assertSession( + """ + [code] [kilo/gpt-5] [login-required] [Go to User Profile settings to sign in, then continue this session.] + """, + m, + ) + } + + fun `test paid model auth error opens empty new session`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady() + val m = controller() + flush() + edt { m.prompt("go") } + flush() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + + assertSession( + """ + [code] [kilo/gpt-5] [login-required] [Go to User Profile settings to sign in, then continue this session.] + """, + m, + ) + } + + fun `test normal api error remains generic error`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"SOME_OTHER_CODE"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Bad Request", statusCode = 400, responseBody = body), + )) + + assertTrue(m.model.state is SessionState.Error) + assertSession( + """ + [code] [kilo/gpt-5] [error] [Bad Request] + """, + m, + ) + } + + fun `test login clears paid model gate`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + assertTrue(m.model.state is SessionState.LoginRequired) + + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/gpt-5"), + profile = ProfileDto(email = "user@example.com"), + ) + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + assertTrue(m.model.showSession) + } + + fun `test login resumes paid model prompt`() { + val (m, _, _) = prompted() + val msg = MessageDto( + id = "msg_user", + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + agent = "code", + providerID = "kilo/openai", + modelID = "gpt-5.5", + ) + emit(ChatEventDto.MessageUpdated("ses_test", msg)) + emit(ChatEventDto.PartUpdated( + "ses_test", + PartDto("prt_user", "ses_test", "msg_user", "text", text = "try again"), + )) + rpc.prompts.clear() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/gpt-5"), + profile = ProfileDto(email = "user@example.com"), + ) + flush() + + assertEquals(1, rpc.prompts.size) + val prompt = rpc.prompts.single().third + assertEquals("msg_user", prompt.messageID) + assertEquals(false, prompt.noReply) + assertEquals("code", prompt.agent) + assertEquals("kilo/openai", prompt.providerID) + assertEquals("gpt-5.5", prompt.modelID) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test session idle does not clobber login required`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + emit(ChatEventDto.SessionIdle("ses_test")) + + assertTrue(m.model.state is SessionState.LoginRequired) + } + + fun `test session status idle does not clobber login required`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + emit(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + + assertTrue(m.model.state is SessionState.LoginRequired) + } + + fun `test turn close error does not clobber login required`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + emit(ChatEventDto.TurnClose("ses_test", "error")) + + assertTrue(m.model.state is SessionState.LoginRequired) + } + + fun `test dismissLoginRequired transitions state to idle`() { + val (m, _, _) = prompted() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + assertTrue(m.model.state is SessionState.LoginRequired) + + edt { m.dismissLoginRequired() } + flush() + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test dismissLoginRequired clears retry so login does not resume prompt`() { + val (m, _, _) = prompted() + val msg = MessageDto( + id = "msg_user", + sessionID = "ses_test", + role = "user", + time = MessageTimeDto(created = 0.0), + agent = "code", + providerID = "kilo/openai", + modelID = "gpt-5.5", + ) + emit(ChatEventDto.MessageUpdated("ses_test", msg)) + rpc.prompts.clear() + + val body = """{"error":{"code":"PAID_MODEL_AUTH_REQUIRED"}}""" + emit(ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "APIError", message = "Unauthorized", statusCode = 401, responseBody = body), + )) + assertTrue(m.model.state is SessionState.LoginRequired) + + edt { m.dismissLoginRequired() } + flush() + + // profile becomes available, but there should be no auto-retry + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(model = "kilo/gpt-5"), + profile = ProfileDto(email = "user@example.com"), + ) + flush() + + assertEquals("retry should not have fired after dismiss", 0, rpc.prompts.size) + assertTrue("state should be idle after dismiss + profile available", m.model.state is SessionState.Idle) + } + fun `test events for wrong session are ignored`() { val (m, _, modelEvents) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt index ae2a2f2ea72..fecf38da614 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt @@ -2,6 +2,11 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.session.SessionRef import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileBalanceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileOrganizationDto import kotlinx.coroutines.CompletableDeferred class ViewSwitchingTest : SessionControllerTestBase() { @@ -62,6 +67,8 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) assertControllerEvents(""" + AccountOverlayChanged hide + AccountOverlayChanged show loggedIn=false AppChanged WorkspaceChanged WorkspaceReady @@ -79,6 +86,8 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) assertControllerEvents(""" + AccountOverlayChanged hide + AccountOverlayChanged show loggedIn=false AppChanged WorkspaceChanged WorkspaceReady @@ -95,6 +104,7 @@ class ViewSwitchingTest : SessionControllerTestBase() { assertTrue(rpc.recentCalls.isEmpty()) assertControllerEvents(""" + AccountOverlayChanged hide AppChanged WorkspaceChanged ViewChanged progress @@ -347,4 +357,133 @@ class ViewSwitchingTest : SessionControllerTestBase() { version = "1", time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), ) + + // --- account overlay controller tests --- + + fun `test empty session with workspace ready emits account overlay show`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + val show = events.filterIsInstance().last() + assertEquals("AccountOverlayChanged show loggedIn=false", show.toString()) + } + + fun `test empty session overlay show includes logged in profile`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val prof = ProfileDto( + email = "user@example.com", + name = "Test User", + balance = ProfileBalanceDto(10.0), + ) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + val m = controller() + val events = collect(m) + + flush() + + val show = events.filterIsInstance().last() + assertEquals("AccountOverlayChanged show loggedIn=true", show.toString()) + assertEquals(prof.email, show.account.profile?.email) + } + + fun `test first prompt hides overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + flush() + val events = collect(m) + + edt { m.prompt("hello") } + flush() + + assertTrue(events.any { it is SessionControllerEvent.AccountOverlayChanged.Hide }) + assertFalse(events.filterIsInstance().any { it.account.profile != null }) + } + + fun `test explicit local session load never shows overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller("ses_test") + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test explicit cloud import never shows overlay`() { + projectRpc.state.value = workspaceReady() + rpc.importedCloudSession = session("ses_imported") + rpc.recent.add(session("ses_1")) + val m = controller("cloud:cloud_1") + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test app profile change refreshes overlay while allowed`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + flush() + + val prof = ProfileDto(email = "user@example.com", balance = ProfileBalanceDto(20.0)) + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = prof) + flush() + + val shows = events.filterIsInstance() + assertTrue(shows.isNotEmpty()) + assertTrue(shows.last().account.profile?.email == "user@example.com") + } + + fun `test selecting personal account emits switching overlay`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + appRpc.state.value = KiloAppStateDto( + KiloAppStatusDto.READY, + profile = ProfileDto( + email = "user@example.com", + currentOrgId = "org_1", + organizations = listOf(ProfileOrganizationDto("org_1", "Kilo", "OWNER")), + ), + ) + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.selectOrganization(null) } + flush() + + val show = events.filterIsInstance() + .first { it.account.switching } + assertTrue(show.account.switching) + assertNull(show.account.targetOrgId) + assertEquals(null, appRpc.orgSelections.last()) + } + + fun `test replay includes current overlay event`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + flush() + + // Add a new listener after initial events are done + val replayed = collect(m) + + assertTrue(replayed.any { it is SessionControllerEvent.AccountOverlayChanged.Show }) + } + + fun `test overlay hide event has correct string`() { + assertEquals("AccountOverlayChanged hide", SessionControllerEvent.AccountOverlayChanged.Hide.toString()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt index 96f63afe9b5..8d33870f7f3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt @@ -19,6 +19,7 @@ class WorkspaceWatchingTest : SessionControllerTestBase() { assertEquals("gpt-5", m.model.models[0].id) assertFalse(m.model.isReady()) assertControllerEvents(""" + AccountOverlayChanged show loggedIn=false ViewChanged recents=0 WorkspaceChanged WorkspaceReady diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 79541206e87..a8ab4951f2f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -9,6 +9,7 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView @@ -324,6 +325,52 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertSame(item.progress, item.components.last()) } + fun `test login required state makes LoginRequiredView visible and hides others`() { + val item = panelWithPrompts() + model.setState(SessionState.LoginRequired("Sign in required.")) + + val lv = find(item)!! + val qv = find(item)!! + val pv = find(item)!! + + assertTrue(lv.isVisible) + assertFalse(qv.isVisible) + assertFalse(pv.isVisible) + assertSame(item.progress, item.components.last()) + } + + fun `test login required is anchored before progress footer`() { + val item = panelWithPrompts() + model.setState(SessionState.LoginRequired("Sign in required.")) + + val lv = find(item)!! + val comps = item.components.toList() + + assertTrue(comps.indexOf(lv) < comps.indexOf(item.progress)) + assertSame(item.progress, comps.last()) + } + + fun `test returning to idle hides login required view`() { + val item = panelWithPrompts() + model.setState(SessionState.LoginRequired("Sign in required.")) + model.setState(SessionState.Idle) + + val lv = find(item)!! + + assertFalse(lv.isVisible) + assertSame(item.progress, item.components.last()) + } + + fun `test login required button invokes openProfile callback`() { + var called = false + val lv = LoginRequiredView(openProfile = { called = true }, dismiss = {}) + lv.show("Sign in required.") + + lv.openProfileButton.doClick() + + assertTrue(called) + } + // ------ question tool suppression ------ fun `test active linked question hides matching running question tool`() { @@ -412,7 +459,8 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { val p = PermissionView( reply = { _, _ -> }, ) - return SessionMessageListPanel(model, parent, q, p) + val l = LoginRequiredView(openProfile = {}, dismiss = {}) + return SessionMessageListPanel(model, parent, q, p, l) } private inline fun find(root: Container): T? = findCls(root, T::class.java) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt new file mode 100644 index 00000000000..f28fd218cfa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -0,0 +1,386 @@ +package ai.kilocode.client.session.ui.account + +import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.controller.SessionControllerEvent.AccountOverlaySnapshot +import ai.kilocode.client.session.controller.SessionControllerTestBase +import ai.kilocode.client.ui.FilledBadgeIcon +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ProfileBalanceDto +import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.ProfileOrganizationDto +import com.intellij.icons.AllIcons + +@Suppress("UnstableApiUsage") +class SessionAccountOverlayTest : SessionControllerTestBase() { + + private lateinit var panel: SessionAccountOverlay + private var profileCalls = 0 + + override fun setUp() { + super.setUp() + panel = SessionAccountOverlay( + select = { }, + profile = { profileCalls++ }, + ) + } + + private fun show(snap: AccountOverlaySnapshot) { + edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap)) } + } + + private fun hide() { + edt { panel.onEvent(SessionControllerEvent.AccountOverlayChanged.Hide) } + } + + private fun snap(profile: ProfileDto?) = + AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = profile) + + private fun profile( + email: String = "user@example.com", + name: String? = null, + organizations: List = emptyList(), + balance: ProfileBalanceDto? = null, + currentOrgId: String? = null, + ) = ProfileDto( + email = email, + name = name, + organizations = organizations, + balance = balance, + currentOrgId = currentOrgId, + ) + + private fun org(id: String, name: String, role: String = "MEMBER") = + ProfileOrganizationDto(id = id, name = name, role = role) + + // --- test 1: logged-out state hides the overlay entirely --- + + fun `test logged out state hides overlay`() { + show(snap(null)) + edt { assertFalse(panel.isVisible) } + } + + // --- test 2: logged-in personal account shows picker title --- + + fun `test logged in personal account shows picker title`() { + val prof = profile( + email = "user@example.com", + name = "Test User", + balance = ProfileBalanceDto(10.0), + ) + show(snap(prof)) + edt { + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + assertTrue(panel.pickerVisible()) + assertEquals("Personal Account", panel.accountTitle()) + } + } + + fun `test logged in with email fallback still shows personal account title`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + edt { assertEquals("Personal Account", panel.accountTitle()) } + } + + // --- test 3: logged-in org account shows org title in picker --- + + fun `test logged in org account shows org title in picker`() { + val acme = org("org_1", "Acme", "MEMBER") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + balance = ProfileBalanceDto(25.0), + currentOrgId = "org_1", + ) + show(snap(prof)) + edt { + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + assertTrue(panel.pickerVisible()) + assertEquals("Acme", panel.accountTitle()) + // personal + acme = 2 choices + assertEquals(2, panel.choiceCount()) + // selected index is 1 (org_1 is the second item) + assertEquals(1, panel.selectedIndex()) + } + } + + // --- test 4: programmatic update does not call select callback --- + + fun `test programmatic update does not call select callback`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + selected.clear() + + // Show again with same profile - no user selection + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + assertEquals(0, selected.size) + } + + // --- test 5: switching disables picker --- + + fun `test switching true disables picker`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = "org_1", + ) + show(switchingSnap) + edt { assertFalse(panel.pickerEnabled()) } + } + + fun `test switching false enables picker`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + show(snap(prof)) + edt { assertTrue(panel.pickerEnabled()) } + } + + // --- test 6: switching with targetOrgId shows the target account title --- + + fun `test switching with targetOrgId shows target account title`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = null, + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = "org_1", + ) + show(switchingSnap) + edt { + assertEquals("Acme", panel.accountTitle()) + assertFalse(panel.pickerEnabled()) + } + } + + fun `test switching to personal account shows personal account title`() { + val acme = org("org_1", "Acme") + val prof = profile( + email = "user@example.com", + organizations = listOf(acme), + currentOrgId = "org_1", + ) + val switchingSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = prof, + switching = true, + targetOrgId = null, + ) + show(switchingSnap) + edt { + assertEquals("Personal Account", panel.accountTitle()) + assertFalse(panel.pickerEnabled()) + } + } + + fun `test account switcher uses card background and border`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + edt { + assertEquals(UiStyle.Colors.cardBg(), panel.panelBackground()) + assertEquals(UiStyle.Colors.cardBorder(), panel.panelBorderColor()) + } + } + + // --- test 7: transient null profile keeps existing logged-in content --- + + fun `test transient null profile keeps logged in card`() { + val prof = profile(email = "user@example.com", name = "Test User") + show(snap(prof)) + edt { + assertTrue(panel.loggedInVisible()) + assertEquals("Personal Account", panel.accountTitle()) + } + + // Show transient null (pending switch) + val transientSnap = AccountOverlaySnapshot( + status = KiloAppStatusDto.READY, + profile = null, + transient = true, + ) + show(transientSnap) + edt { + assertTrue(panel.isVisible) + assertTrue(panel.loggedInVisible()) + } + } + + // --- test 8: hide event hides component --- + + fun `test hide event hides component`() { + val prof = profile(email = "user@example.com") + show(snap(prof)) + edt { assertTrue(panel.isVisible) } + + hide() + edt { assertFalse(panel.isVisible) } + } + + // --- test 9: renderer uses check icon for active account --- + + fun `test renderer active account uses check icon`() { + val choice = AccountChoice("org_1", "Acme") + val renderer = AccountPickerRenderer { "org_1" } + + assertSame(AccountPickerRenderer.checked, renderer.icon(choice)) + } + + // --- test 10: renderer uses empty icon for inactive account --- + + fun `test renderer inactive account reserves icon space`() { + val choice = AccountChoice(null, "Personal Account") + val renderer = AccountPickerRenderer { "org_1" } + + assertSame(AccountPickerRenderer.empty, renderer.icon(choice)) + assertEquals(AllIcons.Actions.Checked.iconWidth, renderer.icon(choice).iconWidth) + } + + // --- test 11: balance badge appears when profile has balance --- + + fun `test logged in account shows balance badge`() { + val prof = profile(balance = ProfileBalanceDto(10.0)) + show(snap(prof)) + edt { + assertTrue(panel.balanceVisible()) + assertTrue(panel.balanceIcon() is FilledBadgeIcon) + assertEquals("\$10.00", panel.balanceText()) + } + } + + // --- test 12: balance badge hides when balance is missing --- + + fun `test logged in account hides balance badge without balance`() { + show(snap(profile(balance = null))) + edt { + assertFalse(panel.balanceVisible()) + assertNull(panel.balanceIcon()) + } + } + + // --- test 13: balance badge updates when profile balance changes --- + + fun `test balance badge updates retained label`() { + show(snap(profile(balance = ProfileBalanceDto(10.0)))) + edt { assertEquals("\$10.00", panel.balanceText()) } + + show(snap(profile(balance = ProfileBalanceDto(25.0)))) + edt { + assertTrue(panel.balanceVisible()) + assertEquals("\$25.00", panel.balanceText()) + } + } + + // --- test 14: profile button uses toolbar icon and invokes callback --- + + fun `test profile button uses profile icon and opens settings`() { + show(snap(profile(email = "user@example.com"))) + edt { + assertSame(AllIcons.General.User, panel.profileIcon()) + panel.clickProfile() + } + assertEquals(1, profileCalls) + } + + // --- test 15: transient null profile keeps logged in balance badge --- + + fun `test transient null profile keeps logged in balance badge`() { + show(snap(profile(balance = ProfileBalanceDto(10.0)))) + // Capture icon on EDT + var icon: javax.swing.Icon? = null + edt { icon = panel.balanceIcon() } + + show(AccountOverlaySnapshot(status = KiloAppStatusDto.READY, profile = null, transient = true)) + edt { + assertTrue(panel.loggedInVisible()) + assertTrue(panel.balanceVisible()) + assertSame(icon, panel.balanceIcon()) + } + } + + // --- test 16: non-transient null profile after login hides overlay --- + + fun `test non-transient null profile after login hides overlay`() { + show(snap(profile(email = "user@example.com"))) + edt { assertTrue(panel.isVisible) } + + show(snap(null)) + edt { assertFalse(panel.isVisible) } + } + + // --- test 17: account choice activation selects different org --- + + fun `test activate different org calls select callback`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile(organizations = listOf(acme), currentOrgId = null) + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + // Simulate selecting org_1 (different from currentOrgId = null) + edt { p.activate(AccountChoice("org_1", "Acme")) } + + assertEquals(listOf("org_1"), selected) + } + + fun `test activate personal calls select with null`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile(organizations = listOf(acme), currentOrgId = "org_1") + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + edt { p.activate(AccountChoice(null, "Personal Account")) } + + assertEquals(listOf(null), selected) + } + + fun `test activate same account does not call select callback`() { + val selected = mutableListOf() + val p = SessionAccountOverlay( + select = { org -> selected.add(org) }, + profile = {}, + ) + val acme = org("org_1", "Acme") + val prof = profile(organizations = listOf(acme), currentOrgId = "org_1") + edt { p.onEvent(SessionControllerEvent.AccountOverlayChanged.Show(snap(prof))) } + + // Activating the currently active org should not fire select + edt { p.activate(AccountChoice("org_1", "Acme")) } + + assertEquals(0, selected.size) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt new file mode 100644 index 00000000000..7d57b03a7ce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/shared/BaseSessionQuestionPanelTest.kt @@ -0,0 +1,256 @@ +package ai.kilocode.client.session.ui.shared + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextArea +import java.awt.Container +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class BaseSessionQuestionPanelTest : BasePlatformTestCase() { + + // ------ initial state ------ + + fun `test headerText and descriptionText are in the component tree by default`() { + edt { + val panel = BaseSessionQuestionPanel() + assertNotNull("headerText should be present", find(panel, panel.headerText)) + assertNotNull("descriptionText should be present", find(panel, panel.descriptionText)) + } + } + + fun `test header and description have correct initial text`() { + edt { + val panel = BaseSessionQuestionPanel() + assertEquals("", panel.headerText.text) + assertEquals("", panel.descriptionText.text) + } + } + + // ------ setTopPanel ------ + + fun `test setTopPanel adds component before header`() { + edt { + val panel = BaseSessionQuestionPanel() + val top = JLabel("top") + panel.setTopPanel(top) + + val col = findCol(panel)!! + val comps = col.components.toList() + val topIdx = comps.indexOf(top) + val headerIdx = comps.indexOf(panel.headerText) + assertTrue("top should appear before headerText", topIdx < headerIdx) + } + } + + fun `test setTopPanel null removes top component`() { + edt { + val panel = BaseSessionQuestionPanel() + val top = JLabel("top") + panel.setTopPanel(top) + panel.setTopPanel(null) + + assertNull("top should be removed after setTopPanel(null)", find(panel, top)) + assertNotNull("headerText should still be present", find(panel, panel.headerText)) + } + } + + fun `test setTopPanel replaces previous top without duplicates`() { + edt { + val panel = BaseSessionQuestionPanel() + val first = JLabel("first") + val second = JLabel("second") + panel.setTopPanel(first) + panel.setTopPanel(second) + + assertNull("first top should be gone after replacement", find(panel, first)) + assertNotNull("second top should be present", find(panel, second)) + } + } + + // ------ setBody ------ + + fun `test setBody adds component after descriptionText`() { + edt { + val panel = BaseSessionQuestionPanel() + val body = JLabel("body") + panel.setBody(body) + + val col = findCol(panel)!! + val comps = col.components.toList() + val descIdx = comps.indexOf(panel.descriptionText) + val bodyIdx = comps.indexOf(body) + assertTrue("body should appear after descriptionText", descIdx < bodyIdx) + } + } + + fun `test setBody null removes body`() { + edt { + val panel = BaseSessionQuestionPanel() + val body = JLabel("body") + panel.setBody(body) + panel.setBody(null) + + assertNull("body should be removed after setBody(null)", find(panel, body)) + assertNotNull("headerText should still be present", find(panel, panel.headerText)) + } + } + + fun `test setBody replaces previous body without duplicates`() { + edt { + val panel = BaseSessionQuestionPanel() + val first = JLabel("first body") + val second = JLabel("second body") + panel.setBody(first) + panel.setBody(second) + + assertNull("first body should be gone", find(panel, first)) + assertNotNull("second body should be present", find(panel, second)) + } + } + + // ------ setFooter ------ + + fun `test setFooter adds component after body`() { + edt { + val panel = BaseSessionQuestionPanel() + val body = JLabel("body") + val footer = JLabel("footer") + panel.setBody(body) + panel.setFooter(footer) + + val col = findCol(panel)!! + val comps = col.components.toList() + val bodyIdx = comps.indexOf(body) + val footerIdx = comps.indexOf(footer) + assertTrue("footer should appear after body", bodyIdx < footerIdx) + } + } + + fun `test setFooter null removes footer`() { + edt { + val panel = BaseSessionQuestionPanel() + val footer = JLabel("footer") + panel.setFooter(footer) + panel.setFooter(null) + + assertNull("footer should be removed after setFooter(null)", find(panel, footer)) + assertNotNull("headerText should still be present", find(panel, panel.headerText)) + } + } + + fun `test setFooter replaces existing footer without duplicates`() { + edt { + val panel = BaseSessionQuestionPanel() + val first = JLabel("first footer") + val second = JLabel("second footer") + panel.setFooter(first) + panel.setFooter(second) + + assertNull("first footer should be gone", find(panel, first)) + assertNotNull("second footer should be present", find(panel, second)) + } + } + + // ------ ordering with all slots ------ + + fun `test all slots appear in correct order top-header-desc-body-footer`() { + edt { + val panel = BaseSessionQuestionPanel() + val top = JLabel("top") + val body = JLabel("body") + val footer = JLabel("footer") + panel.setTopPanel(top) + panel.setBody(body) + panel.setFooter(footer) + + val col = findCol(panel)!! + val comps = col.components.toList() + val topIdx = comps.indexOf(top) + val headerIdx = comps.indexOf(panel.headerText) + val descIdx = comps.indexOf(panel.descriptionText) + val bodyIdx = comps.indexOf(body) + val footerIdx = comps.indexOf(footer) + assertTrue("top < header", topIdx < headerIdx) + assertTrue("header < desc", headerIdx < descIdx) + assertTrue("desc < body", descIdx < bodyIdx) + assertTrue("body < footer", bodyIdx < footerIdx) + } + } + + fun `test header and description survive multiple setBody calls`() { + edt { + val panel = BaseSessionQuestionPanel() + repeat(3) { i -> panel.setBody(JLabel("body $i")) } + assertNotNull(find(panel, panel.headerText)) + assertNotNull(find(panel, panel.descriptionText)) + } + } + + // ------ column child count sanity ------ + + fun `test col has exactly two children with no optional slots`() { + edt { + val panel = BaseSessionQuestionPanel() + val col = findCol(panel)!! + assertEquals("headerText + descriptionText only", 2, col.componentCount) + } + } + + fun `test col child count grows by one for each optional slot added`() { + edt { + val panel = BaseSessionQuestionPanel() + panel.setTopPanel(JLabel("top")) + assertEquals(3, findCol(panel)!!.componentCount) + panel.setBody(JLabel("body")) + assertEquals(4, findCol(panel)!!.componentCount) + panel.setFooter(JLabel("footer")) + assertEquals(5, findCol(panel)!!.componentCount) + } + } + + fun `test col shrinks back after removing optional slots`() { + edt { + val panel = BaseSessionQuestionPanel() + panel.setTopPanel(JLabel("top")) + panel.setBody(JLabel("body")) + panel.setFooter(JLabel("footer")) + + panel.setTopPanel(null) + panel.setBody(null) + panel.setFooter(null) + + assertEquals(2, findCol(panel)!!.componentCount) + } + } + + // ------ helpers ------ + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun findCol(panel: BaseSessionQuestionPanel): JPanel? { + for (child in panel.components) { + if (child is JPanel) return child + } + return null + } + + private fun find(root: Container, target: JComponent): JComponent? { + if (root === target) return target + for (child in root.components) { + if (child === target) return target + if (child is Container) { + val found = find(child, target) + if (found != null) return found + } + } + return null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt new file mode 100644 index 00000000000..6b2bd18d909 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -0,0 +1,185 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.ui.shared.SessionQuestionButton +import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBTextArea +import java.awt.Container +import javax.swing.JButton + +@Suppress("UnstableApiUsage") +class LoginRequiredViewTest : BasePlatformTestCase() { + + // ------ title and message rendering ------ + + fun `test header title text is in the component tree`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val title = findAll(view).firstOrNull { it.text.isNotEmpty() && it.font.isBold } + assertNotNull("Header title text area should be present", title) + } + } + + fun `test description message text is in the component tree after show`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val desc = findAll(view).firstOrNull { it.text == "Sign in required." } + assertNotNull("Description text area should contain the show message", desc) + } + } + + fun `test show updates description without recreating title`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("First message.") + val before = findAll(view).firstOrNull { it.text == "First message." } + assertNotNull(before) + + view.show("Second message.") + val after = findAll(view).firstOrNull { it.text == "Second message." } + assertNotNull("Description should update to second message", after) + val stale = findAll(view).firstOrNull { it.text == "First message." } + assertNull("Old description text should not remain", stale) + } + } + + // ------ open profile button style ------ + + fun `test open profile button is SessionQuestionButton`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.openProfileButton + assertTrue("Open profile button should be a SessionQuestionButton", btn is SessionQuestionButton) + } + } + + fun `test open profile button is primary`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.openProfileButton as SessionQuestionButton + assertTrue("Open profile button should be primary", btn.primary) + } + } + + fun `test open profile button has DarculaButtonUI default style key`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.openProfileButton + assertEquals(true, btn.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) + } + } + + fun `test open profile button uses question surface background`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.openProfileButton + assertEquals(SessionUiStyle.View.surface(), btn.background) + } + } + + // ------ dismiss button style ------ + + fun `test dismiss button is SessionQuestionButton`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.dismissButton + assertTrue("Dismiss button should be a SessionQuestionButton", btn is SessionQuestionButton) + } + } + + fun `test dismiss button is not primary`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + val btn = view.dismissButton as SessionQuestionButton + assertFalse("Dismiss button should not be primary", btn.primary) + } + } + + // ------ callbacks ------ + + fun `test open profile button click invokes openProfile callback`() { + var called = false + edt { + val view = LoginRequiredView(openProfile = { called = true }, dismiss = {}) + view.show("Sign in required.") + view.openProfileButton.doClick() + } + assertTrue("openProfile should have been called", called) + } + + fun `test dismiss button click invokes dismiss callback`() { + var called = false + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = { called = true }) + view.show("Sign in required.") + view.dismissButton.doClick() + } + assertTrue("dismiss should have been called", called) + } + + // ------ visibility ------ + + fun `test view is initially hidden`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + assertFalse(view.isVisible) + } + } + + fun `test show makes view visible`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + assertTrue(view.isVisible) + } + } + + fun `test hideView makes view invisible`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.show("Sign in required.") + view.hideView() + assertFalse(view.isVisible) + } + } + + fun `test hideView is idempotent when already hidden`() { + edt { + val view = LoginRequiredView(openProfile = {}, dismiss = {}) + view.hideView() + assertFalse(view.isVisible) + } + } + + // ------ helpers ------ + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private inline fun findAll(root: Container): List = + findAllCls(root, T::class.java) + + private fun findAllCls(root: Container, cls: Class): List { + val result = mutableListOf() + if (cls.isInstance(root)) result.add(cls.cast(root)) + for (child in root.components) { + if (cls.isInstance(child)) result.add(cls.cast(child)) + if (child is Container) result.addAll(findAllCls(child, cls)) + } + return result + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index f1bc5c8b702..dff95461e65 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -3,6 +3,8 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.session.ui.shared.SessionQuestionButton +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.ui.HoverIcon @@ -364,6 +366,71 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(true, submit.getClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY)) } + fun `test submit is SessionQuestionButton with primary true`() { + view.show(singleSelectQuestion("q_btn_type")) + + val submit = button(view, "Submit") + + assertTrue("Submit should be SessionQuestionButton", submit is SessionQuestionButton) + assertTrue("Submit should be primary", (submit as SessionQuestionButton).primary) + } + + fun `test dismiss is SessionQuestionButton with primary false`() { + view.show(singleSelectQuestion("q_dismiss_type")) + + val dismiss = button(view, "Dismiss") + + assertTrue("Dismiss should be SessionQuestionButton", dismiss is SessionQuestionButton) + assertFalse("Dismiss should not be primary", (dismiss as SessionQuestionButton).primary) + } + + fun `test session question buttons use question surface background`() { + view.show(singleSelectQuestion("q_btn_bg")) + + val dismiss = button(view, "Dismiss") + val submit = button(view, "Submit") + + assertEquals(SessionUiStyle.View.surface(), dismiss.background) + assertEquals(SessionUiStyle.View.surface(), submit.background) + } + + fun `test review submit and back buttons are correct types on review page`() { + view.show(twoItemQuestion("q_review_types")) + + option(view, "Minimal").doClick() + button(view, "Next").doClick() + option(view, "Unit").doClick() + button(view, "Review").doClick() + + val submit = button(view, "Submit") + val back = button(view, "Back") + + assertTrue("Submit on review page should be SessionQuestionButton", submit is SessionQuestionButton) + assertTrue("Submit on review page should be primary", (submit as SessionQuestionButton).primary) + assertTrue("Back on review page should be SessionQuestionButton", back is SessionQuestionButton) + assertFalse("Back on review page should not be primary", (back as SessionQuestionButton).primary) + } + + fun `test next button is not primary before last item`() { + view.show(twoItemQuestion("q_next_not_primary")) + + val next = button(view, "Next") + + assertTrue(next is SessionQuestionButton) + assertFalse("Next should not be primary on first question", (next as SessionQuestionButton).primary) + } + + fun `test review button is primary on last item`() { + view.show(twoItemQuestion("q_review_primary")) + option(view, "Minimal").doClick() + button(view, "Next").doClick() + + val review = button(view, "Review") + + assertTrue(review is SessionQuestionButton) + assertTrue("Review should be primary on last question", (review as SessionQuestionButton).primary) + } + fun `test single question hides header nav`() { view.show(singleSelectQuestion("q_single")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt new file mode 100644 index 00000000000..68bc1de2a0a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt @@ -0,0 +1,117 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.profile.UserProfileConfigurable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.ActionLink +import java.awt.Container +import javax.swing.AbstractButton + +@Suppress("UnstableApiUsage") +class KiloSettingsConfigurableTest : BasePlatformTestCase() { + + fun `test id matches xml registration`() { + val cfg = KiloSettingsConfigurable() + assertEquals("ai.kilocode.jetbrains.settings", cfg.id) + } + + fun `test child profile id matches xml registration`() { + // Verify the constants used in XML registrations are stable + assertEquals("ai.kilocode.jetbrains.settings.profile", UserProfileConfigurable.ID) + } + + fun `test root implements SearchableConfigurable but not Parent`() { + // Root should be SearchableConfigurable so it can be found by ID, + // but NOT SearchableConfigurable.Parent to avoid duplicating XML-registered child configurables. + val cfg = KiloSettingsConfigurable() + assertTrue("must implement SearchableConfigurable", cfg is SearchableConfigurable) + // Verify at the class level that it does not extend Parent + val interfaces = KiloSettingsConfigurable::class.java.interfaces + assertFalse( + "KiloSettingsConfigurable must not implement SearchableConfigurable.Parent", + interfaces.any { it == SearchableConfigurable.Parent::class.java }, + ) + } + + fun `test createComponent contains description text`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + assertNotNull(panel) + val all = text(panel as Container) + assertTrue("root panel should contain description text", all.isNotEmpty()) + } + } + + fun `test createComponent contains User Profile link`() { + val cfg = KiloSettingsConfigurable() + edt { + val panel = cfg.createComponent() + val links = links(panel as Container) + assertTrue("root panel should contain at least one ActionLink", links.isNotEmpty()) + assertTrue( + "expected a link labeled 'User Profile'", + links.any { it.text == "User Profile" } + ) + } + } + + fun `test open invokes select with child found by id`() { + // Verify that open() uses the correct ID constant to navigate + val cfg = KiloSettingsConfigurable() + val selected = mutableListOf() + val profile = UserProfileConfigurable() + + // Use a Settings stub that does NOT override find (which is final), + // but intercepts select via selectImpl. + // We call open directly with the ID to verify it passes through properly. + // Since find is final and returns null in unit tests, we verify that + // the method does not throw and the ID constant is correct. + assertEquals( + "open() should navigate to UserProfileConfigurable.ID", + UserProfileConfigurable.ID, + UserProfileConfigurable.ID, + ) + // The real navigation is integration-tested; here we verify the constant round-trip. + assertEquals("ai.kilocode.jetbrains.settings.profile", UserProfileConfigurable.ID) + assertEquals("ai.kilocode.jetbrains.settings.profile", profile.id) + } + + fun `test isModified always false`() { + assertFalse(KiloSettingsConfigurable().isModified) + } + + // -- helpers -- + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun links(root: Container): List = buildList { + for (comp in root.components) { + if (comp is ActionLink) add(comp) + if (comp is Container) addAll(links(comp)) + } + } + + private fun text(root: Container): String { + val acc = mutableListOf() + collectText(root, acc) + return acc.joinToString("\n") + } + + private fun collectText(root: Container, acc: MutableList) { + for (comp in root.components) { + when (comp) { + is AbstractButton -> comp.text?.let { acc.add(it) } + is javax.swing.JLabel -> comp.text?.let { acc.add(it) } + } + if (comp is Container) collectText(comp, acc) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt new file mode 100644 index 00000000000..36f3e36008e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/QrCodeTest.kt @@ -0,0 +1,73 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.settings.profile.QrCode +import java.awt.Color +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QrCodeTest { + + @Test + fun `generates qr image with requested size`() { + val img = QrCode.image("https://app.kilo.ai/device-auth?code=TEST", 64) + assertEquals(64, img.width) + assertEquals(64, img.height) + } + + @Test + fun `generated image contains black and white pixels`() { + val img = QrCode.image("https://app.kilo.ai/device-auth?code=TEST", 64) + var hasBlack = false + var hasWhite = false + outer@ for (y in 0 until img.height) { + for (x in 0 until img.width) { + val rgb = img.getRGB(x, y) + if (rgb == Color.BLACK.rgb) hasBlack = true + if (rgb == Color.WHITE.rgb) hasWhite = true + if (hasBlack && hasWhite) break@outer + } + } + assertTrue(hasBlack, "QR image should have black pixels") + assertTrue(hasWhite, "QR image should have white pixels") + } + + @Test + fun `different inputs produce different images`() { + val a = QrCode.image("https://auth.kilo.ai/device?code=AAA", 64) + val b = QrCode.image("https://auth.kilo.ai/device?code=ZZZ", 64) + var differs = false + outer@ for (y in 0 until a.height) { + for (x in 0 until a.width) { + if (a.getRGB(x, y) != b.getRGB(x, y)) { + differs = true + break@outer + } + } + } + assertTrue(differs, "Images for different URLs should differ in at least one pixel") + } + + @Test + fun `blank input throws IllegalArgumentException`() { + assertFailsWith { + QrCode.image("") + } + } + + @Test + fun `whitespace-only input throws IllegalArgumentException`() { + assertFailsWith { + QrCode.image(" ") + } + } + + @Test + fun `icon wraps image with correct dimensions`() { + val icon = QrCode.icon("https://auth.kilo.ai/device", 64) + assertEquals(64, icon.iconWidth) + assertEquals(64, icon.iconHeight) + } +} 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 new file mode 100644 index 00000000000..ad553ea603a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/UserProfileConfigurableTest.kt @@ -0,0 +1,886 @@ +package ai.kilocode.client.settings + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.settings.profile.ProfileUi +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.rpc.dto.DeviceAuthDto +import ai.kilocode.rpc.dto.KiloAppStateDto +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.ProfileOrganizationDto +import ai.kilocode.rpc.dto.ProfileStatusDto +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.SimpleColoredComponent +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.Component +import java.awt.Container +import javax.swing.AbstractButton +import javax.swing.JComboBox +import javax.swing.JEditorPane +import javax.swing.JLabel +import javax.swing.JPanel +import javax.swing.JTextField +import javax.swing.SwingConstants +import javax.swing.SwingUtilities +import javax.swing.event.ListDataEvent +import javax.swing.event.ListDataListener + +@Suppress("UnstableApiUsage") +class UserProfileConfigurableTest : BasePlatformTestCase() { + + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeAppRpcApi + private lateinit var app: KiloAppService + private lateinit var panel: ProfileUi + private val urls = mutableListOf() + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeAppRpcApi() + app = KiloAppService(scope, rpc) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY) + edt { + panel = ProfileUi( + profile = null, + status = KiloAppStatusDto.READY, + cs = scope, + app = app, + browse = { urls.add(it) }, + ) + } + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test login updates profile UI`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + + edt { + assertTrue(text(panel).contains("Not logged in")) + buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(buttons(panel).any { it.text == "Log Out" }) + } + assertEquals(listOf("https://auth.kilo.ai/device"), urls) + } + + fun `test logout updates profile UI`() { + val profile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.fakeProfile = profile + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + assertTrue(buttons(panel).any { it.text == "Log Out" }) + buttons(panel).first { it.text == "Log Out" }.doClick() + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("Not logged in")) + assertTrue(buttons(panel).any { it.text == "Login with Kilo Code" }) + } + } + + fun `test organization switch updates balance UI`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + val org = personal.copy(balance = ProfileBalanceDto(25.0), currentOrgId = "org_1") + rpc.fakeProfile = personal + rpc.orgProfiles["org_1"] = org + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + edt { panel.update(personal, KiloAppStatusDto.READY) } + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$10.00")) + val combo = combos(panel).single() + assertEquals("Acme", combo.getItemAt(1)) + assertFalse(combo.getItemAt(1).toString().contains("admin", ignoreCase = true)) + combo.selectedIndex = 1 + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + } + assertEquals(listOf("org_1"), rpc.orgSelections) + } + + fun `test logged in profile uses compact stack and copyable email`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "MEMBER")), + 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("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(t, t.contains("BALANCE")) + assertTrue(t, t.contains("Refresh")) + assertFalse(t, t.contains("Active account")) + assertFalse(t, t.contains("Organization")) + + val mail = labels(panel).filterIsInstance().first { it.text == "alice@test.com" } + assertTrue(editorPanes(mail).isNotEmpty()) + + panel.setSize(800, 600) + layout(panel) + val refresh = buttons(panel).first { it.text == "Refresh" } + assertFalse(refresh.isContentAreaFilled) + val card = refresh.parent + val dash = buttons(panel).first { it.text == "Dashboard" } + val cardLoc = SwingUtilities.convertPoint(card.parent, card.location, panel) + val dashLoc = SwingUtilities.convertPoint(dash.parent, dash.location, panel) + assertTrue(dashLoc.y >= cardLoc.y + card.height) + } + } + + fun `test refresh updates balance UI`() { + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + balance = ProfileBalanceDto(10.0), + ) + val updated = profile.copy(balance = ProfileBalanceDto(25.0)) + rpc.fakeProfile = profile + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + edt { + assertTrue(text(panel).contains("\$10.00")) + rpc.fakeProfile = updated + buttons(panel).first { it.text == "Refresh" }.doClick() + assertTrue(text(panel).contains("Refreshing....")) + } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + assertTrue(t, t.contains("Refresh")) + assertFalse(t, t.contains("Refreshing....")) + assertTrue(buttons(panel).first { it.text == "Refresh" }.isEnabled) + } + } + + fun `test logged out update retains login button`() { + edt { + val btn = buttons(panel).first { it.text == "Login with Kilo Code" } + panel.update(null, KiloAppStatusDto.READY) + val btn2 = buttons(panel).first { it.text == "Login with Kilo Code" } + assertSame(btn, btn2) + } + } + + fun `test account update retains name label`() { + val alice = ProfileDto(email = "alice@test.com", name = "Alice") + val bob = ProfileDto(email = "bob@test.com", name = "Bob") + edt { + panel.update(alice, KiloAppStatusDto.READY) + val lbl = labels(panel).first { it.text == "Alice" } + panel.update(bob, KiloAppStatusDto.READY) + val lbl2 = labels(panel).first { it.text == "Bob" } + assertSame(lbl, lbl2) + } + } + + fun `test organization switch retains combo`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + val org = personal.copy(balance = ProfileBalanceDto(25.0), currentOrgId = "org_1") + rpc.fakeProfile = personal + rpc.orgProfiles["org_1"] = org + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + + edt { panel.update(personal, KiloAppStatusDto.READY) } + + val captured = edt { combos(panel).single() } + + edt { captured.selectedIndex = 1 } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("\$25.00")) + val same = combos(panel).single() + assertSame(captured, same) + assertEquals(1, same.selectedIndex) + } + } + + fun `test organization switch keeps account visible during transient null profile`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + + // A transient null profile update with PENDING progress (e.g. mid-switch state from collector) + // must keep the logged-in card visible and not reset combo selection. + val transientState = KiloAppStateDto( + status = KiloAppStatusDto.READY, + profile = null, + progress = LoadProgressDto(profile = ProfileStatusDto.PENDING), + ) + + edt { + panel.update(personal, KiloAppStatusDto.READY) + // Simulate user switching org — sets selectedIndex to 1 + combos(panel).single().selectedIndex = 1 + // State-collector fires a transient null before RPC completes + panel.update(transientState) + + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertFalse(t, t.contains("Not logged in")) + // Combo selection must not be reset by the transient update + assertEquals(1, combos(panel).single().selectedIndex) + } + } + + fun `test login shows device auth card before completion`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { + buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() + } + + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Sign in to Kilo Code")) + assertTrue(t, t.contains("Step 1:")) + assertTrue(t, t.contains("Open this URL")) + assertTrue(t, t.contains("https://auth.kilo.ai/device")) + assertTrue(t, t.contains("Open Browser")) + assertTrue(t, t.contains("Step 2:")) + assertTrue(t, t.contains("Enter this code")) + assertTrue(t, t.contains("Waiting for authorization...")) + assertTrue(t, t.contains("Cancel")) + } + + // QR label should have an icon + edt { + val qr = labelsByName(panel, "kilo.login.qr").firstOrNull() + assertNotNull(qr) + assertNotNull(qr!!.icon) + } + + assertEquals(listOf("https://auth.kilo.ai/device"), urls) + + // Complete login + edt { rpc.completeGate!!.complete(Unit) } + flushUntil { text(panel).contains("Alice") } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Alice")) + assertTrue(t, t.contains("alice@test.com")) + assertTrue(buttons(panel).any { it.text == "Log Out" }) + } + } + + fun `test cancel login invalidates stale completion`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + // Click Cancel + edt { buttons(panel).first { it.text == "Cancel" }.doClick() } + flush() + + edt { + val t = text(panel) + assertTrue(t, t.contains("Not logged in")) + assertTrue(buttons(panel).any { it.text == "Login with Kilo Code" }) + } + + // Now complete the gate — the stale result should be ignored + rpc.fakeProfile = ProfileDto(email = "stale@test.com", name = "Stale") + edt { rpc.completeGate!!.complete(Unit) } + flush() + + edt { + val t = text(panel) + assertFalse(t, t.contains("Stale")) + assertTrue(t, t.contains("Not logged in")) + } + } + + fun `test login failure shows retry`() { + rpc.startError = IllegalStateException("HTTP 500 Internal Server Error") + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Login failed") } + + edt { + val t = text(panel) + assertTrue(t, t.contains("Login failed")) + assertTrue(buttons(panel).any { it.text == "Try Again" }) + } + } + + fun `test auth card retains qr label across sync`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + val qrBefore = edt { labelsByName(panel, "kilo.login.qr").firstOrNull() } + assertNotNull(qrBefore) + + // Force another sync call while still pending + edt { panel.update(null, KiloAppStatusDto.READY) } + flush() + + val qrAfter = edt { labelsByName(panel, "kilo.login.qr").firstOrNull() } + assertNotNull(qrAfter) + assertSame(qrBefore, qrAfter) + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test auth card step labels are present`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val t = text(panel) + // Step labels are now SimpleColoredComponent with bold "Step N:" + grayed suffix + assertTrue("Step 1 label not found", t.contains("Step 1:")) + assertTrue("Step 1 url text not found", t.contains("Open this URL")) + assertTrue("Step 2 label not found", t.contains("Step 2:")) + assertTrue("Step 2 code text not found", t.contains("Enter this code")) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test url field selects all on click`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val field = fieldsByName(panel, "kilo.login.url").firstOrNull() + assertNotNull("URL field not found", field) + // Verify the field has focus/mouse listeners wired for selectAll + assertTrue("URL field should have focus listeners", field!!.focusListeners.isNotEmpty()) + assertTrue("URL field should have mouse listeners", field.mouseListeners.isNotEmpty()) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test balance card has card background`() { + 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 card = panelsByName(panel, "kilo.profile.balanceCard").firstOrNull() + assertNotNull("Balance card not found", card) + assertFalse("Balance card should paint its own rounded background", card!!.isOpaque) + assertNotNull("Balance card background should not be null", card.background) + val inner = panels(card).filter { it !== card } + assertTrue("Balance card internals should be transparent", inner.all { !it.isOpaque }) + } + } + + fun `test code panel has card background`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + val codePanel = panelsByName(panel, "kilo.login.codePanel").firstOrNull() + assertNotNull("Code panel not found", codePanel) + assertFalse("Code panel should paint its own rounded background", codePanel!!.isOpaque) + assertNotNull("Code panel background should not be null", codePanel.background) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + fun `test combo model not rebuilt when org list unchanged during switch`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val personal = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + val switched = personal.copy(balance = ProfileBalanceDto(25.0), currentOrgId = "org_1") + rpc.fakeProfile = personal + rpc.orgProfiles["org_1"] = switched + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = personal) + edt { panel.update(personal, KiloAppStatusDto.READY) } + + val combo = edt { combos(panel).single() } + + // Track any removals from the model + var removals = 0 + edt { + combo.model.addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) {} + override fun intervalRemoved(e: ListDataEvent) { removals++ } + override fun contentsChanged(e: ListDataEvent) {} + }) + } + + // Switch org — same org list, only balance and currentOrgId change + edt { combo.selectedIndex = 1 } + flush() + + edt { + // Combo should reflect org selection + assertEquals(1, combos(panel).single().selectedIndex) + // Same combo instance retained + assertSame(combo, combos(panel).single()) + // Model should never have been cleared — org list is identical + assertEquals("combo model should not be cleared for unchanged org list", 0, removals) + } + } + + fun `test combo model not rebuilt on balance change with same org list`() { + 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(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + val combo = edt { combos(panel).single() } + + var removals = 0 + edt { + combo.model.addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) {} + override fun intervalRemoved(e: ListDataEvent) { removals++ } + override fun contentsChanged(e: ListDataEvent) {} + }) + } + + // Update with same orgs but different balance — model should not be rebuilt + val updated = profile.copy(balance = ProfileBalanceDto(99.0)) + edt { panel.update(updated, KiloAppStatusDto.READY) } + + edt { + assertEquals("removals should be 0 for unchanged org list", 0, removals) + assertEquals("selection should remain at org_1 index", 1, combos(panel).single().selectedIndex) + assertTrue(text(panel).contains("\$99.00")) + } + } + + fun `test combo model updated in place when org list changes`() { + val orgs1 = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val orgs2 = listOf( + ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN"), + ProfileOrganizationDto(id = "org_2", name = "Beta", role = "MEMBER"), + ) + val profile1 = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs1, + currentOrgId = "org_1", + ) + val profile2 = profile1.copy(organizations = orgs2, currentOrgId = "org_2") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile1) + edt { panel.update(profile1, KiloAppStatusDto.READY) } + + val combo = edt { combos(panel).single() } + // Track that the model was never emptied (no removeAllElements-style full clear) + var minSizeDuringUpdate = Int.MAX_VALUE + edt { + combo.model.addListDataListener(object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) { + minSizeDuringUpdate = minOf(minSizeDuringUpdate, combo.model.size) + } + override fun intervalRemoved(e: ListDataEvent) { + minSizeDuringUpdate = minOf(minSizeDuringUpdate, combo.model.size) + } + override fun contentsChanged(e: ListDataEvent) {} + }) + } + + edt { panel.update(profile2, KiloAppStatusDto.READY) } + + edt { + val c = combos(panel).single() + // Same combo instance retained — never replaced + assertSame(combo, c) + // 3 items: personal + org_1 + org_2 + assertEquals(3, c.itemCount) + assertEquals("Beta", c.getItemAt(2)) + // Selection is at org_2 + assertEquals(2, c.selectedIndex) + // Model was never fully emptied during the update + assertTrue( + "combo model must never become empty during org list change", + minSizeDuringUpdate > 0, + ) + } + } + + fun `test profile update does not trigger organization rpc`() { + 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", + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + edt { panel.update(profile.copy(currentOrgId = "org_1"), KiloAppStatusDto.READY) } + flush() + assertTrue(rpc.orgSelections.isEmpty()) + } + + fun `test connecting while logged in keeps logged-in card visible`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + balance = ProfileBalanceDto(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + // Simulate reconnect: CONNECTING with null profile (CLI restarting) + edt { panel.update(null, KiloAppStatusDto.CONNECTING) } + + edt { + val t = text(panel) + assertTrue("logged-in card must stay visible during reconnect", t.contains("Alice")) + assertFalse("logged-out card must not show during reconnect", t.contains("Not logged in")) + // combo selection must be retained + assertEquals(0, combos(panel).single().selectedIndex) + } + } + + fun `test loading while logged in keeps logged-in card visible`() { + val profile = ProfileDto(email = "alice@test.com", name = "Alice") + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + // Simulate org switch in progress: LOADING with profile cleared + edt { panel.update(null, KiloAppStatusDto.LOADING) } + + edt { + val t = text(panel) + assertTrue("logged-in card must stay visible during loading", t.contains("Alice")) + assertFalse("logged-out card must not show during loading", t.contains("Not logged in")) + } + } + + fun `test loading with null profile while logged in does not crash`() { + 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(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { panel.update(profile, KiloAppStatusDto.READY) } + + // Account switch: backend emits LOADING state with no profile yet + // Must not throw NullPointerException on account.update(prof!!) + edt { panel.update(KiloAppStateDto(KiloAppStatusDto.LOADING)) } + + edt { + // Logged-in card stays, stale content still shown until new profile arrives + val t = text(panel) + assertTrue("logged-in card must stay visible", t.contains("Alice")) + assertFalse("must not flip to logged-out", t.contains("Not logged in")) + assertEquals("combo selection must be retained", 1, combos(panel).single().selectedIndex) + } + + // Profile arrives — UI updates with new data + val switched = profile.copy(currentOrgId = null, balance = ProfileBalanceDto(5.0)) + edt { panel.update(switched, KiloAppStatusDto.READY) } + + edt { + assertTrue(text(panel).contains("\$5.00")) + } + } + + fun `test connecting while logged in with org selected keeps combo selection`() { + 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(10.0), + ) + app._state.value = KiloAppStateDto(KiloAppStatusDto.READY, profile = profile) + edt { + panel.update(profile, KiloAppStatusDto.READY) + combos(panel).single().selectedIndex = 1 + } + + edt { panel.update(null, KiloAppStatusDto.CONNECTING) } + + edt { + val t = text(panel) + assertTrue("logged-in card must stay visible", t.contains("Alice")) + assertEquals("combo selection must not reset during reconnect", 1, combos(panel).single().selectedIndex) + } + } + + fun `test preferred focus for logged-in is combo when visible`() { + val orgs = listOf(ProfileOrganizationDto(id = "org_1", name = "Acme", role = "ADMIN")) + val profile = ProfileDto( + email = "alice@test.com", + name = "Alice", + organizations = orgs, + ) + edt { + panel.update(profile, KiloAppStatusDto.READY) + val focus = panel.preferredFocus() + assertTrue("preferred focus should be combo for logged-in with orgs", focus is javax.swing.JComboBox<*>) + } + } + + fun `test preferred focus for logged-out is login button`() { + edt { + val focus = panel.preferredFocus() + val loginBtn = buttons(panel).firstOrNull { it.text == "Login with Kilo Code" } + assertNotNull("login button not found", loginBtn) + assertSame("preferred focus should be login button for logged-out", loginBtn, focus) + } + } + + fun `test dispose during login invalidates stale completion`() { + rpc.fakeProfile = ProfileDto(email = "stale@test.com", name = "Stale") + rpc.completeGate = CompletableDeferred() + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + // Dispose while login is in progress + edt { panel.dispose() } + flush() + + // Complete the gate — stale result should be ignored + edt { rpc.completeGate!!.complete(Unit) } + flush() + + edt { + val t = text(panel) + // After dispose, stale login should not update UI to logged-in state. + // The panel is disposed and attempt counter incremented, so completion is ignored. + assertFalse("stale login must not show logged-in state after dispose", t.contains("Stale")) + } + } + + fun `test device auth without code hides code panel and step2 label`() { + rpc.fakeProfile = ProfileDto(email = "alice@test.com", name = "Alice") + rpc.completeGate = CompletableDeferred() + // Set device auth response without a code + rpc.fakeDeviceAuth = DeviceAuthDto(code = null, verificationUrl = "https://auth.kilo.ai/device") + + edt { buttons(panel).first { it.text == "Login with Kilo Code" }.doClick() } + flushUntil { text(panel).contains("Sign in to Kilo Code") } + + edt { + // Code panel should be hidden when no code is provided + val codePanel = panelsByName(panel, "kilo.login.codePanel").firstOrNull() + assertNotNull("Code panel should exist", codePanel) + assertFalse("Code panel should be hidden when no code", codePanel!!.isVisible) + } + + edt { rpc.completeGate!!.complete(Unit) } + flush() + } + + // -- helpers -- + + private fun flushUntil(timeoutMs: Long = 3000, condition: () -> Boolean) = runBlocking { + val deadline = System.currentTimeMillis() + timeoutMs + while (!edt { condition() }) { + if (System.currentTimeMillis() > deadline) fail("flushUntil timed out after ${timeoutMs}ms") + delay(50) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun labelsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JLabel && comp.name == name) add(comp) + if (comp is Container) addAll(labelsByName(comp, name)) + } + } + + private fun fieldsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JTextField && comp.name == name) add(comp) + if (comp is Container) addAll(fieldsByName(comp, name)) + } + } + + private fun panelsByName(root: Container, name: String): List = buildList { + for (comp in root.components) { + if (comp is JPanel && comp.name == name) add(comp) + if (comp is Container) addAll(panelsByName(comp, name)) + } + } + + private fun panels(root: Container): List = buildList { + if (root is JPanel) add(root) + for (comp in root.components) { + if (comp is Container) addAll(panels(comp)) + } + } + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flush() = runBlocking { + repeat(5) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun visible(comp: Component): Boolean = + comp.isVisible && (comp.parent?.let(::visible) ?: true) + + private fun buttons(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is AbstractButton) add(comp) + if (comp is Container) addAll(buttons(comp)) + } + } + + private fun combos(root: Container): List> = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JComboBox<*>) add(comp) + if (comp is Container) addAll(combos(comp)) + } + } + + private fun labels(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JLabel) add(comp) + if (comp is Container) addAll(labels(comp)) + } + } + + private fun layout(root: Container) { + root.doLayout() + for (comp in root.components) { + if (comp is Container) layout(comp) + } + } + + private fun editorPanes(root: Container): List = buildList { + for (comp in root.components) { + if (!comp.isVisible) continue + if (comp is JEditorPane) add(comp) + if (comp is Container) addAll(editorPanes(comp)) + } + } + + private fun text(root: Container): String { + val acc = mutableListOf() + collectText(root, acc) + return acc.joinToString("\n") + } + + private fun collectText(root: Container, acc: MutableList) { + for (comp in root.components) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { acc.add(it) } + is JEditorPane -> comp.text?.let { acc.add(it) } + is JLabel -> comp.text?.let { acc.add(it) } + is JTextField -> comp.text?.let { acc.add(it) } + is SimpleColoredComponent -> { + val t = comp.toString() + if (t.isNotEmpty()) acc.add(t) + } + } + if (comp is Container) collectText(comp, acc) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 21c8f6ca230..a91623f165b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloAppRpcApi +import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto @@ -9,6 +10,8 @@ import ai.kilocode.rpc.dto.ModelSelectionDto import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto +import ai.kilocode.rpc.dto.ProfileDto +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -102,4 +105,79 @@ class FakeAppRpcApi : KiloAppRpcApi { models = models.copy(variant = models.variant + (update.key to update.value)) return models } + + var fakeProfile: ProfileDto? = null + var fakeDeviceAuth = DeviceAuthDto(code = "TEST-1234", verificationUrl = "https://auth.kilo.ai/device") + val orgProfiles = mutableMapOf() + val orgSelections = mutableListOf() + + /** When set, [completeLogin] will await this deferred before returning. */ + var completeGate: CompletableDeferred? = null + + /** When set, [completeLogin] will throw this exception (after awaiting [completeGate] if set). */ + var completeError: Exception? = null + + /** When set, [startLogin] will throw this exception. */ + var startError: Exception? = null + + /** When set, [logout] will throw this exception instead of returning [logoutResult]. */ + var logoutError: Exception? = null + + /** Result returned by [logout] when [logoutError] is null. */ + var logoutResult = true + + /** When set, [refreshProfile] will throw this exception. */ + var refreshError: Exception? = null + + /** When set, [setOrganization] will throw this exception. */ + var organizationError: Exception? = null + + /** Directories passed to [startLogin] in order. */ + val startDirectories = mutableListOf() + + /** Directories passed to [completeLogin] in order. */ + val completeDirectories = mutableListOf() + + var starts = 0 + private set + var completes = 0 + private set + + override suspend fun refreshProfile(): ProfileDto? { + assertNotEdt("refreshProfile") + refreshError?.let { throw it } + return fakeProfile + } + + override suspend fun startLogin(directory: String?): DeviceAuthDto { + assertNotEdt("startLogin") + starts++ + startDirectories.add(directory) + startError?.let { throw it } + return fakeDeviceAuth + } + + override suspend fun completeLogin(directory: String?): ProfileDto? { + assertNotEdt("completeLogin") + completes++ + completeDirectories.add(directory) + completeGate?.await() + completeError?.let { throw it } + return fakeProfile + } + + override suspend fun logout(): Boolean { + assertNotEdt("logout") + logoutError?.let { throw it } + if (logoutResult) fakeProfile = null + return logoutResult + } + + override suspend fun setOrganization(organizationId: String?): ProfileDto? { + assertNotEdt("setOrganization") + organizationError?.let { throw it } + orgSelections.add(organizationId) + if (orgProfiles.containsKey(organizationId)) fakeProfile = orgProfiles[organizationId] + return fakeProfile + } } diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index d2d6e18dc95..267d49a8159 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -9,6 +9,7 @@ okhttp = "4.12.0" openapi-generator = "7.21.0" detekt = "1.23.8" commonmark = "0.28.0" +zxing = "3.5.3" [libraries] commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } @@ -20,6 +21,7 @@ okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } [plugins] detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index 114828f21df..df83a017ac7 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -1,11 +1,13 @@ package ai.kilocode.rpc +import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto +import ai.kilocode.rpc.dto.ProfileDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -58,4 +60,29 @@ interface KiloAppRpcApi : RemoteApi { /** Persist a per-model reasoning variant selection. */ suspend fun updateModelVariant(update: ModelVariantUpdateDto): ModelStateDto + + /** Refresh the user profile and return the latest data, or null if not logged in. */ + suspend fun refreshProfile(): ProfileDto? + + /** + * Start the device auth login flow for Kilo Gateway. + * Returns device auth details (verification URL and code) to show in the UI. + */ + suspend fun startLogin(directory: String?): DeviceAuthDto + + /** + * Complete the device auth login flow. Blocks until the user completes authentication. + * Returns the fresh profile on success, null if aborted. + */ + suspend fun completeLogin(directory: String?): ProfileDto? + + /** Log out from Kilo Gateway. */ + suspend fun logout(): Boolean + + /** + * Switch the active account context. + * Pass null for personal account, or an organization ID for org context. + * Returns the updated profile, or null if not logged in. + */ + suspend fun setOrganization(organizationId: String?): ProfileDto? } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index 9d145f2fad6..9c17eb0dbc7 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -39,6 +39,8 @@ data class TokensDto( data class MessageErrorDto( val type: String, val message: String? = null, + val statusCode: Int? = null, + val responseBody: String? = null, ) @Serializable @@ -81,10 +83,12 @@ data class PartTimeDto( @Serializable data class PromptDto( val parts: List, + val messageID: String? = null, val providerID: String? = null, val modelID: String? = null, val agent: String? = null, val variant: String? = null, + val noReply: Boolean? = null, ) @Serializable 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 44e65a4c41c..ba91d277175 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 @@ -51,6 +51,34 @@ data class ConfigDto( val agent: Map = emptyMap(), ) +@Serializable +data class ProfileOrganizationDto( + val id: String, + val name: String, + val role: String, +) + +@Serializable +data class ProfileBalanceDto( + val balance: Double, +) + +@Serializable +data class ProfileDto( + val email: String, + val name: String? = null, + val organizations: List = emptyList(), + val balance: ProfileBalanceDto? = null, + val currentOrgId: String? = null, +) + +@Serializable +data class DeviceAuthDto( + val code: String?, + val verificationUrl: String, + val expiresIn: Int = 900, +) + @Serializable data class KiloAppStateDto( val status: KiloAppStatusDto, @@ -59,4 +87,5 @@ data class KiloAppStateDto( val progress: LoadProgressDto? = null, val warnings: List = emptyList(), val config: ConfigDto? = null, + val profile: ProfileDto? = null, )