mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge pull request #10461 from Kilo-Org/conscious-jackrabbit
feat(jetbrains): native profile settings, account overlay, and paid-model login
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Fix JetBrains startup when command templates are returned as lazy objects.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show a sign-in prompt in JetBrains sessions when a paid model requires login.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show account login, switching, and balance controls on the empty JetBrains session screen.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+95
-1
@@ -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
|
||||
|
||||
+1
-7
@@ -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 }
|
||||
}
|
||||
|
||||
+106
-2
@@ -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<CommandInfo> =
|
||||
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<String> {
|
||||
val keys = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList()
|
||||
return keys.sortedWith(compareBy<String> { 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<String, String> {
|
||||
|
||||
+27
@@ -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,
|
||||
|
||||
+3
-71
@@ -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>(KiloWorkspaceState.Pending)
|
||||
@@ -219,7 +207,7 @@ class KiloBackendWorkspace(
|
||||
|
||||
private fun fetchProviders(): FetchResult<ProviderData> =
|
||||
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<List<CommandInfo>> =
|
||||
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<String> {
|
||||
val raw = obj["variants"]?.jsonObject?.keys?.toList() ?: return emptyList()
|
||||
return raw.sortedWith(compareBy<String> { 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
|
||||
|
||||
+197
@@ -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<Unit> {
|
||||
// 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<Unit> {
|
||||
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<KiloAppState.Ready>(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")
|
||||
}
|
||||
}
|
||||
|
||||
+1250
-1019
File diff suppressed because it is too large
Load Diff
+26
-1
@@ -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)
|
||||
|
||||
+7
-15
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
+38
@@ -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
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -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() {}
|
||||
}
|
||||
|
||||
|
||||
+32
@@ -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
|
||||
}
|
||||
+163
-4
@@ -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(" ")
|
||||
|
||||
+20
@@ -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"
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-4
@@ -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()
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
}
|
||||
+69
@@ -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<AccountChoice> {
|
||||
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<out AccountChoice>,
|
||||
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
|
||||
}
|
||||
+300
@@ -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<AccountChoice> = 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()
|
||||
}
|
||||
+6
-36
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-45
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+177
@@ -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<Pair<JBTextArea, Boolean>>()
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
}
|
||||
+41
@@ -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() } }
|
||||
+85
@@ -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()
|
||||
}
|
||||
}
|
||||
+41
-60
@@ -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<Pair<JBTextArea, Boolean>>()
|
||||
|
||||
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<String>) {
|
||||
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
|
||||
|
||||
+64
@@ -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"
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
+265
@@ -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<String>()
|
||||
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<java.awt.Component> = 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<Pair<String?, String>> = 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<Pair<String?, String>> = 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<Pair<String?, String>>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+395
@@ -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)
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
+290
@@ -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("<!doctype html", "<html", "<head", "<body")
|
||||
private val HTTP_STATUS_RE = Regex("""(?:^|\s)([45]\d{2})(?:\s|$)""")
|
||||
|
||||
internal fun compactLoginError(e: Exception): String {
|
||||
val msg = e.message?.trim() ?: return KiloBundle.message("profile.login.failed")
|
||||
val lower = msg.lowercase()
|
||||
if (HTML_MARKERS.any { lower.contains(it) }) {
|
||||
val status = HTTP_STATUS_RE.find(msg)?.groupValues?.getOrNull(1)
|
||||
return if (status != null) "${KiloBundle.message("profile.login.failed")} ($status)"
|
||||
else KiloBundle.message("profile.login.failed")
|
||||
}
|
||||
val norm = msg.replace(Regex("\\s+"), " ")
|
||||
val summary = norm.take(180)
|
||||
return if (summary.isNotBlank()) summary else KiloBundle.message("profile.login.failed")
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ai.kilocode.client.settings.profile
|
||||
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
import javax.swing.ImageIcon
|
||||
|
||||
internal object QrCode {
|
||||
|
||||
/**
|
||||
* Generate a QR code image for [text].
|
||||
*
|
||||
* Uses black modules on a white background regardless of IDE theme — this is
|
||||
* intentional for scanning reliability (QR scanners expect high contrast B/W).
|
||||
*
|
||||
* @param text URL or text to encode; must not be blank.
|
||||
* @param size pixel dimension for both width and height.
|
||||
* @throws IllegalArgumentException if [text] is blank.
|
||||
*/
|
||||
fun image(text: String, size: Int = 160): BufferedImage {
|
||||
require(text.isNotBlank()) { "QR text must not be blank" }
|
||||
val hints = mapOf(EncodeHintType.MARGIN to 2)
|
||||
val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val img = BufferedImage(size, size, BufferedImage.TYPE_INT_RGB)
|
||||
for (y in 0 until size) {
|
||||
for (x in 0 until size) {
|
||||
img.setRGB(x, y, if (matrix[x, y]) Color.BLACK.rgb else Color.WHITE.rgb)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that returns the QR code as an [ImageIcon].
|
||||
*
|
||||
* @param text URL or text to encode; must not be blank.
|
||||
* @param size pixel dimension for both width and height.
|
||||
*/
|
||||
fun icon(text: String, size: Int = 160): ImageIcon = ImageIcon(image(text, size))
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package ai.kilocode.client.settings.profile
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.ModalityState
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.options.SearchableConfigurable
|
||||
import com.intellij.openapi.wm.IdeFocusManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.swing.JComponent
|
||||
|
||||
/**
|
||||
* Settings panel for Kilo user profile.
|
||||
*
|
||||
* Located at Settings -> Tools -> Kilo -> User Profile.
|
||||
*
|
||||
* Shows login / logout, current balance, personal/org account selector,
|
||||
* and a link to the Kilo dashboard. This is a status/action panel — it
|
||||
* 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<KiloAppService>()
|
||||
return ProfileUi(app.state.value.profile, app.state.value.status, cs)
|
||||
}
|
||||
|
||||
private fun startWatching(cs: CoroutineScope, panel: ProfileUi) {
|
||||
val app = service<KiloAppService>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
+42
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-12
@@ -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())
|
||||
}
|
||||
|
||||
+61
@@ -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()
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
icon="/icons/kilo.svg"
|
||||
factoryClass="ai.kilocode.client.KiloToolWindowFactory"/>
|
||||
|
||||
<applicationConfigurable
|
||||
parentId="tools"
|
||||
id="ai.kilocode.jetbrains.settings"
|
||||
instance="ai.kilocode.client.settings.KiloSettingsConfigurable"
|
||||
bundle="messages.KiloBundle"
|
||||
key="settings.kilo.displayName"/>
|
||||
|
||||
<applicationConfigurable
|
||||
parentId="ai.kilocode.jetbrains.settings"
|
||||
id="ai.kilocode.jetbrains.settings.profile"
|
||||
instance="ai.kilocode.client.settings.profile.UserProfileConfigurable"
|
||||
bundle="messages.KiloBundle"
|
||||
key="settings.profile.displayName"/>
|
||||
|
||||
<registryKey key="kilo.session.condense"
|
||||
description="Enable event condensing in the session update queue (merges redundant snapshots before model delivery)."
|
||||
defaultValue="true"
|
||||
@@ -40,9 +54,23 @@
|
||||
class="ai.kilocode.client.actions.KiloSettingsAction"
|
||||
icon="AllIcons.General.GearPlain"/>
|
||||
|
||||
<action id="Kilo.NewSession"
|
||||
class="ai.kilocode.client.actions.NewSessionAction"/>
|
||||
|
||||
<action id="Kilo.History"
|
||||
class="ai.kilocode.client.actions.HistoryAction"/>
|
||||
|
||||
<action id="Kilo.ShowProfile"
|
||||
class="ai.kilocode.client.actions.ShowProfileAction"/>
|
||||
|
||||
<!-- Declarative toolbar group for the Kilo tool window title bar -->
|
||||
<group id="Kilo.ToolWindowToolbar">
|
||||
<reference ref="Kilo.NewSession"/>
|
||||
<reference ref="Kilo.History"/>
|
||||
<reference ref="Kilo.ShowProfile"/>
|
||||
<reference ref="Kilo.Settings"/>
|
||||
</group>
|
||||
|
||||
<action id="Kilo.SendPrompt"
|
||||
class="ai.kilocode.client.actions.SendPromptAction"
|
||||
text="Send Prompt"
|
||||
|
||||
@@ -6,6 +6,8 @@ session.connection.retry=Try again
|
||||
session.connection.warning.config=Configuration warnings
|
||||
|
||||
session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.
|
||||
session.account.balance=Balance: {0}
|
||||
session.account.switcher=Switch account
|
||||
session.empty.loading=Loading...
|
||||
session.empty.recent=RECENT
|
||||
session.showHistory=Show History
|
||||
@@ -55,6 +57,11 @@ session.error.prompt=Prompt failed
|
||||
session.error.compact=Session compact failed
|
||||
session.error.unknown=Unknown error
|
||||
|
||||
session.login.required.title=You need to sign in to use this model
|
||||
session.login.required.description=Go to User Profile settings to sign in, then continue this session.
|
||||
session.login.required.button=Open User Profile
|
||||
session.login.required.dismiss=Dismiss
|
||||
|
||||
session.header.tokens=Tokens
|
||||
session.header.tokens.description=Tokens used by the latest assistant response: input, output, cache writes, and cache reads.
|
||||
session.header.input=in {0}
|
||||
@@ -131,6 +138,48 @@ action.Kilo.NewSession.text=New Session
|
||||
action.Kilo.NewSession.description=Start a new Kilo session
|
||||
action.Kilo.History.text=History
|
||||
action.Kilo.History.description=Show session history
|
||||
action.Kilo.ShowProfile.text=Profile
|
||||
action.Kilo.ShowProfile.description=Open Kilo user profile settings
|
||||
action.Kilo.ToolWindowToolbar.text=Kilo Toolbar
|
||||
settings.kilo.displayName=Kilo Code
|
||||
settings.kilo.description=Configure Kilo Code AI coding assistant features and account settings.
|
||||
settings.profile.displayName=User Profile
|
||||
profile.group.account=Account
|
||||
profile.group.organization=Organization
|
||||
profile.label.account=Active account:
|
||||
profile.notLoggedIn=Not logged in
|
||||
profile.status.connecting=Connecting to Kilo...
|
||||
profile.status.error=Connection error
|
||||
profile.balance.title=BALANCE
|
||||
profile.personalAccount=Personal Account
|
||||
profile.switchingAccount=Switching account...
|
||||
profile.action.login=Login with Kilo Code
|
||||
profile.action.logout=Log Out
|
||||
profile.action.dashboard=Dashboard
|
||||
profile.action.retry=Retry
|
||||
profile.action.refresh=Refresh
|
||||
profile.action.refreshing=Refreshing....
|
||||
profile.login.signingIn=Signing in to Kilo Code
|
||||
profile.login.urlLabel=Open this URL:
|
||||
profile.login.codeLabel=Enter this code:
|
||||
profile.login.waiting=Waiting for authorization...
|
||||
profile.login.cancel=Cancel
|
||||
profile.login.starting=Starting login...
|
||||
profile.login.title=Sign in to Kilo Code
|
||||
profile.login.step.one=Step 1:
|
||||
profile.login.step.url=Open this URL
|
||||
profile.login.copyUrl=Copy URL
|
||||
profile.login.openBrowser=Open Browser
|
||||
profile.login.qr=QR Code
|
||||
profile.login.qr.description=Scan to open the sign-in URL
|
||||
profile.login.step.two=Step 2:
|
||||
profile.login.step.code=Enter this code
|
||||
profile.login.clickToCopy=Click to copy
|
||||
profile.login.waitingTimed=Waiting for authorization... ({0})
|
||||
profile.login.failed=Login failed
|
||||
profile.login.tryAgain=Try Again
|
||||
profile.login.urlCopied=URL copied to clipboard
|
||||
profile.login.codeCopied=Code copied to clipboard
|
||||
action.Kilo.SendPrompt.text=Send Prompt
|
||||
action.Kilo.SendPrompt.description=Send the current Kilo prompt
|
||||
action.Kilo.StopSession.text=Stop Session
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package ai.kilocode.client.app
|
||||
|
||||
import ai.kilocode.client.testing.FakeAppRpcApi
|
||||
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 com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Service-level tests for [KiloAppService] profile/login/logout/org operations.
|
||||
*
|
||||
* Uses [FakeAppRpcApi] to avoid RPC/backend involvement.
|
||||
*/
|
||||
@Suppress("UnstableApiUsage")
|
||||
class KiloAppServiceTest : BasePlatformTestCase() {
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var rpc: FakeAppRpcApi
|
||||
private lateinit var app: KiloAppService
|
||||
|
||||
override fun setUp() {
|
||||
super.setUp()
|
||||
scope = CoroutineScope(SupervisorJob())
|
||||
rpc = FakeAppRpcApi()
|
||||
app = KiloAppService(scope, rpc)
|
||||
app._state.value = KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
}
|
||||
|
||||
override fun tearDown() {
|
||||
try {
|
||||
scope.cancel()
|
||||
} finally {
|
||||
super.tearDown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun profile(
|
||||
email: String = "alice@test.com",
|
||||
name: String? = "Alice",
|
||||
balance: ProfileBalanceDto? = null,
|
||||
orgs: List<ProfileOrganizationDto> = 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<String?>("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<String?>(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<String?>(null), rpc.startDirectories)
|
||||
}
|
||||
}
|
||||
+80
@@ -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"),
|
||||
)
|
||||
}
|
||||
|
||||
+83
-1
@@ -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<SessionRootPanel>(ui)
|
||||
val overlay = find<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionAccountOverlay>(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<SessionRootPanel>(ui)
|
||||
val overlay = find<SessionAccountOverlay>(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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+87
@@ -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"}}"""),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+7
@@ -248,6 +248,13 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() {
|
||||
ApplicationManager.getApplication().invokeAndWait(block)
|
||||
}
|
||||
|
||||
protected fun <T> 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) }
|
||||
|
||||
+227
@@ -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()
|
||||
|
||||
|
||||
+139
@@ -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<SessionControllerEvent.AccountOverlayChanged.Show>().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<SessionControllerEvent.AccountOverlayChanged.Show>().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<SessionControllerEvent.AccountOverlayChanged.Show>().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<SessionControllerEvent.AccountOverlayChanged.Show>()
|
||||
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<SessionControllerEvent.AccountOverlayChanged.Show>()
|
||||
.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())
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+49
-1
@@ -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<LoginRequiredView>(item)!!
|
||||
val qv = find<QuestionView>(item)!!
|
||||
val pv = find<PermissionView>(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<LoginRequiredView>(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<LoginRequiredView>(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 <reified T> find(root: Container): T? = findCls(root, T::class.java)
|
||||
|
||||
+386
@@ -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<ProfileOrganizationDto> = 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<String?>()
|
||||
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<String?>()
|
||||
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<String?>("org_1"), selected)
|
||||
}
|
||||
|
||||
fun `test activate personal calls select with null`() {
|
||||
val selected = mutableListOf<String?>()
|
||||
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<String?>(null), selected)
|
||||
}
|
||||
|
||||
fun `test activate same account does not call select callback`() {
|
||||
val selected = mutableListOf<String?>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
+256
@@ -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 <T> 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
|
||||
}
|
||||
}
|
||||
+185
@@ -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<JBTextArea>(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<JBTextArea>(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<JBTextArea>(view).firstOrNull { it.text == "First message." }
|
||||
assertNotNull(before)
|
||||
|
||||
view.show("Second message.")
|
||||
val after = findAll<JBTextArea>(view).firstOrNull { it.text == "Second message." }
|
||||
assertNotNull("Description should update to second message", after)
|
||||
val stale = findAll<JBTextArea>(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 <T> edt(block: () -> T): T {
|
||||
var result: T? = null
|
||||
ApplicationManager.getApplication().invokeAndWait { result = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
|
||||
private inline fun <reified T> findAll(root: Container): List<T> =
|
||||
findAllCls(root, T::class.java)
|
||||
|
||||
private fun <T> findAllCls(root: Container, cls: Class<T>): List<T> {
|
||||
val result = mutableListOf<T>()
|
||||
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
|
||||
}
|
||||
}
|
||||
+67
@@ -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<JBRadioButton>(view, "Minimal").doClick()
|
||||
button(view, "Next").doClick()
|
||||
option<JBRadioButton>(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<JBRadioButton>(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"))
|
||||
|
||||
|
||||
+117
@@ -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<Configurable>()
|
||||
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 <T> 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<ActionLink> = 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<String>()
|
||||
collectText(root, acc)
|
||||
return acc.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun collectText(root: Container, acc: MutableList<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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<IllegalArgumentException> {
|
||||
QrCode.image("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whitespace-only input throws IllegalArgumentException`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+886
@@ -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<String>()
|
||||
|
||||
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<JBLabel>().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 <!doctype html><body>Internal Server Error</body>")
|
||||
|
||||
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<JLabel> = 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<JTextField> = 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<JPanel> = 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<JPanel> = buildList {
|
||||
if (root is JPanel) add(root)
|
||||
for (comp in root.components) {
|
||||
if (comp is Container) addAll(panels(comp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> edt(block: () -> T): T {
|
||||
var result: T? = null
|
||||
ApplicationManager.getApplication().invokeAndWait { result = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as T
|
||||
}
|
||||
|
||||
private fun 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<AbstractButton> = 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<JComboBox<*>> = 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<JLabel> = 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<JEditorPane> = 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<String>()
|
||||
collectText(root, acc)
|
||||
return acc.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun collectText(root: Container, acc: MutableList<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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<String?, ProfileDto?>()
|
||||
val orgSelections = mutableListOf<String?>()
|
||||
|
||||
/** When set, [completeLogin] will await this deferred before returning. */
|
||||
var completeGate: CompletableDeferred<Unit>? = 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<String?>()
|
||||
|
||||
/** Directories passed to [completeLogin] in order. */
|
||||
val completeDirectories = mutableListOf<String?>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<Unit> {
|
||||
|
||||
/** 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?
|
||||
}
|
||||
|
||||
@@ -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<PromptPartDto>,
|
||||
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
|
||||
|
||||
@@ -51,6 +51,34 @@ data class ConfigDto(
|
||||
val agent: Map<String, AgentConfigDto> = 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<ProfileOrganizationDto> = 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<ConfigWarningDto> = emptyList(),
|
||||
val config: ConfigDto? = null,
|
||||
val profile: ProfileDto? = null,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user