feat(jetbrains): add KiloAppState with loading phase, retry, and error details

Introduce KiloAppState lifecycle: Disconnected → Connecting → Loading →
Ready → Error. Config and notifications are required (retried 3×),
profile 401 is treated as not-logged-in (not an error).

Each failed fetch captures HTTP status code and error detail from
ClientException/ServerException. The tool window displays granular
loading progress per resource and detailed error information including
HTTP status and response messages.
This commit is contained in:
kirillk
2026-04-12 15:29:47 -04:00
parent a729eaba1a
commit 600f4df93b
13 changed files with 437 additions and 111 deletions
@@ -0,0 +1,51 @@
package ai.kilocode.backend
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
/**
* Full application lifecycle state, combining CLI transport connection
* status with data-loading progress.
*
* [ConnectionState] stays internal to [KiloConnectionService] for the
* transport layer. This sealed class is what the frontend observes.
*/
sealed class KiloAppState {
data object Disconnected : KiloAppState()
data object Connecting : KiloAppState()
data class Loading(val progress: LoadProgress) : KiloAppState()
data class Ready(val data: AppData) : KiloAppState()
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloAppState()
}
/**
* Tracks which global data fetches have completed during the [KiloAppState.Loading] phase.
*/
data class LoadProgress(
val config: Boolean = false,
val notifications: Boolean = false,
val profile: ProfileResult = ProfileResult.PENDING,
)
/** Outcome of the profile fetch. */
enum class ProfileResult { PENDING, LOADED, NOT_LOGGED_IN }
/**
* Error detail for a single resource that failed to load.
*/
data class LoadError(
val resource: String,
val status: Int? = null,
val detail: String? = null,
)
/**
* All global data that has been successfully loaded.
* Present only in [KiloAppState.Ready].
*/
data class AppData(
val profile: KiloProfile200Response?,
val config: Config,
val notifications: List<KiloNotifications200ResponseInner>,
)
@@ -1,6 +1,8 @@
package ai.kilocode.backend
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.jetbrains.api.infrastructure.ClientException
import ai.kilocode.jetbrains.api.infrastructure.ServerException
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
@@ -8,14 +10,19 @@ import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicReference
/**
* App-level orchestrator that owns the CLI server lifecycle and
@@ -29,35 +36,39 @@ import kotlinx.coroutines.sync.withLock
* [KiloBackendCliManager] and [KiloConnectionService] perform no
* internal synchronization — they rely on this mutex.
*
* Data flows use the generated OpenAPI model types directly —
* no intermediate DTOs needed at the backend layer.
* After the CLI server connects, the app enters a [KiloAppState.Loading]
* phase. Config and notifications are required (retried up to 3×).
* Profile is optional — 401 (not logged in) is not an error.
*/
@Service(Service.Level.APP)
class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
companion object {
private val LOG = Logger.getInstance(KiloBackendAppService::class.java)
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
}
private val mutex = Mutex()
private val server = KiloBackendCliManager()
private val connection = KiloConnectionService(cs, server) {
// onReconnect callback — invoked when the CLI process dies and
// a full restart is needed. Launches under the mutex so it's
// serialized with user-initiated connect/restart/reinstall.
cs.launch { reconnect() }
}
private var router: Job? = null
private var loader: Job? = null
// ── Delegated state ─────────────────────────────────────────────
// ── App state ───────────────────────────────────────────────────
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
val appState: StateFlow<KiloAppState> = _appState.asStateFlow()
// ── Delegated from connection (internal use) ────────────────────
val state: StateFlow<ConnectionState> get() = connection.state
val events: SharedFlow<SseEvent> get() = connection.events
val api: DefaultApi? get() = connection.api
// ── Global data (project-independent) ───────────────────────────
// ── Cached data (also held inside KiloAppState.Ready) ───────────
@Volatile var profile: KiloProfile200Response? = null
private set
@@ -72,7 +83,8 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
suspend fun connect() {
mutex.withLock {
if (state.value is ConnectionState.Connected || state.value is ConnectionState.Connecting) return
val current = _appState.value
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) return
connection.connect()
}
}
@@ -100,14 +112,10 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
// ── Internals ───────────────────────────────────────────────────
/**
* Full reconnect triggered when the CLI process dies.
* Serialized by the same mutex as user-initiated operations.
*/
private suspend fun reconnect() {
mutex.withLock {
val current = state.value
if (current is ConnectionState.Connected || current is ConnectionState.Connecting) {
val current = _appState.value
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) {
LOG.info("reconnect: already ${current::class.simpleName} — skipping")
return
}
@@ -117,12 +125,13 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
}
init {
// Watch connection state — load global data on each (re)connect.
cs.launch {
connection.state.collect { next ->
if (next is ConnectionState.Connected) {
load()
ensureRouter()
when (next) {
ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected
ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting
is ConnectionState.Connected -> load()
is ConnectionState.Error -> _appState.value = KiloAppState.Error(next.message)
}
}
}
@@ -130,76 +139,166 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
/**
* Launch all project-independent data fetches in parallel.
* Each fetch is independent — a failure in one does not block the others.
*
* Config and notifications are required — retried up to [MAX_RETRIES] times.
* Profile is optional — 401 (not logged in) is fine.
*
* Progress is tracked via [LoadProgress] and emitted as [KiloAppState.Loading].
* On success, transitions to [KiloAppState.Ready].
* On failure of required data, transitions to [KiloAppState.Error].
*/
private fun load() {
loader?.cancel()
loader = cs.launch {
LOG.info("Loading global data")
coroutineScope {
launch { loadProfile() }
launch { loadConfig() }
launch { loadNotifications() }
val progress = AtomicReference(LoadProgress())
_appState.value = KiloAppState.Loading(progress.get())
val errors = mutableListOf<LoadError>()
try {
coroutineScope {
launch {
val result = fetchProfile()
progress.updateAndGet { it.copy(profile = result) }
.also { _appState.value = KiloAppState.Loading(it) }
}
launch {
val result = fetchWithRetry("config") { fetchConfig() }
if (result.value != null) {
config = result.value
progress.updateAndGet { it.copy(config = true) }
.also { _appState.value = KiloAppState.Loading(it) }
} else {
val err = result.error!!
synchronized(errors) { errors.add(err) }
throw LoadFailure(err)
}
}
launch {
val result = fetchWithRetry("notifications") { fetchNotifications() }
if (result.value != null) {
notifications = result.value
progress.updateAndGet { it.copy(notifications = true) }
.also { _appState.value = KiloAppState.Loading(it) }
} else {
val err = result.error!!
synchronized(errors) { errors.add(err) }
throw LoadFailure(err)
}
}
}
_appState.value = KiloAppState.Ready(
AppData(
profile = profile,
config = config!!,
notifications = notifications,
)
)
LOG.info("Global data loaded — app is Ready")
ensureRouter()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LOG.warn("Global data load failed: ${e.message}")
_appState.value = KiloAppState.Error(
message = "Failed to load required data",
errors = synchronized(errors) { errors.toList() },
)
}
LOG.info("Global data loaded")
}
}
private suspend fun loadProfile() {
val client = connection.api ?: return
try {
// ── Individual fetch functions ──────────────────────────────────
private suspend fun fetchProfile(): ProfileResult {
val client = connection.api ?: return ProfileResult.NOT_LOGGED_IN
return try {
val response = client.kiloProfile()
profile = response
LOG.info("Profile: ${response.profile.email}")
ProfileResult.LOADED
} catch (e: Exception) {
// 401 = not logged in to Kilo Gateway — expected, not an error
LOG.info("Profile fetch skipped: ${e.message}")
ProfileResult.NOT_LOGGED_IN
}
}
private suspend fun loadConfig() {
val client = connection.api ?: return
try {
val response = client.globalConfigGet()
config = response
LOG.info("Global config loaded")
private suspend fun fetchConfig(): FetchResult<Config> {
val client = connection.api
?: return FetchResult.fail("config", detail = "Not connected")
return try {
FetchResult.ok(client.globalConfigGet())
} catch (e: Exception) {
LOG.warn("Global config fetch failed", e)
LOG.warn("Global config fetch failed: ${e.message}")
FetchResult.fail("config", e)
}
}
private suspend fun loadNotifications() {
val client = connection.api ?: return
try {
val response = client.kiloNotifications()
notifications = response
LOG.info("Notifications: ${response.size} items")
private suspend fun fetchNotifications(): FetchResult<List<KiloNotifications200ResponseInner>> {
val client = connection.api
?: return FetchResult.fail("notifications", detail = "Not connected")
return try {
FetchResult.ok(client.kiloNotifications())
} catch (e: Exception) {
LOG.warn("Notifications fetch failed", e)
LOG.warn("Notifications fetch failed: ${e.message}")
FetchResult.fail("notifications", e)
}
}
/**
* Route SSE events that require global data reloads.
* Only one router runs at a time — restarted on reconnect.
*/
// ── Retry helper ────────────────────────────────────────────────
private suspend fun <T> fetchWithRetry(
name: String,
block: suspend () -> FetchResult<T>,
): FetchResult<T> {
var last: FetchResult<T> = FetchResult.fail(name, detail = "No attempts made")
repeat(MAX_RETRIES) { attempt ->
last = block()
if (last.value != null) return last
if (attempt < MAX_RETRIES - 1) {
LOG.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms")
delay(RETRY_DELAY_MS)
}
}
LOG.error("$name: all $MAX_RETRIES attempts failed")
return last
}
// ── SSE event routing ───────────────────────────────────────────
private fun ensureRouter() {
if (router?.isActive == true) return
router = cs.launch {
connection.events.collect { event ->
when (event.type) {
"global.config.updated" -> launch { loadConfig() }
"global.config.updated" -> launch {
val result = fetchConfig()
if (result.value != null) {
config = result.value
val current = _appState.value
if (current is KiloAppState.Ready) {
_appState.value = current.copy(
data = current.data.copy(config = result.value)
)
}
}
}
}
}
}
}
// ── Cleanup ─────────────────────────────────────────────────────
private fun clear() {
loader?.cancel()
router?.cancel()
profile = null
config = null
notifications = emptyList()
_appState.value = KiloAppState.Disconnected
}
override fun dispose() {
@@ -208,3 +307,45 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
server.dispose()
}
}
/**
* Result of a data fetch — either a value or an error with details.
*/
private data class FetchResult<T>(val value: T?, val error: LoadError?) {
companion object {
fun <T> ok(value: T) = FetchResult<T>(value, null)
fun <T> fail(resource: String, exception: Exception) = FetchResult<T>(
value = null,
error = LoadError(
resource = resource,
status = httpStatus(exception),
detail = httpDetail(exception),
),
)
fun <T> fail(resource: String, detail: String) = FetchResult<T>(
value = null,
error = LoadError(resource = resource, detail = detail),
)
private fun httpStatus(e: Exception): Int? =
when (e) {
is ClientException -> e.statusCode
is ServerException -> e.statusCode
else -> null
}
private fun httpDetail(e: Exception): String? =
when (e) {
is ClientException -> "HTTP ${e.statusCode}: ${e.message}"
is ServerException -> "HTTP ${e.statusCode}: ${e.message}"
is java.net.ConnectException -> "Connection refused: ${e.message}"
is java.net.SocketTimeoutException -> "Timeout: ${e.message}"
else -> e.message
}
}
}
/** Thrown when a required data fetch exhausts all retries. */
private class LoadFailure(val error: LoadError) : Exception("Failed to load ${error.resource}")
@@ -36,9 +36,9 @@ class KiloBackendProjectService(
val directory: String
get() = project.basePath ?: ""
/** Connection state (delegates to app-level service). */
val state: StateFlow<ConnectionState>
get() = app.state
/** App lifecycle state (delegates to app-level service). */
val state: StateFlow<KiloAppState>
get() = app.appState
/** Ensure the CLI backend is running and connected. */
suspend fun connect() = app.connect()
@@ -2,12 +2,18 @@
package ai.kilocode.backend.rpc
import ai.kilocode.backend.ConnectionState
import ai.kilocode.backend.KiloAppState
import ai.kilocode.backend.KiloBackendAppService
import ai.kilocode.backend.LoadError
import ai.kilocode.backend.LoadProgress
import ai.kilocode.backend.ProfileResult
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.LoadProgressDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import com.intellij.openapi.components.service
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -25,8 +31,8 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
override suspend fun connect() = app.connect()
override suspend fun state(): Flow<ConnectionStateDto> =
app.state.map(::dto).distinctUntilChanged()
override suspend fun state(): Flow<KiloAppStateDto> =
app.appState.map(::dto).distinctUntilChanged()
override suspend fun health(): HealthDto = app.health()
@@ -34,11 +40,35 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
override suspend fun reinstall() = app.reinstall()
private fun dto(state: ConnectionState): ConnectionStateDto =
private fun dto(state: KiloAppState): KiloAppStateDto =
when (state) {
ConnectionState.Disconnected -> ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
ConnectionState.Connecting -> ConnectionStateDto(ConnectionStatusDto.CONNECTING)
is ConnectionState.Connected -> ConnectionStateDto(ConnectionStatusDto.CONNECTED)
is ConnectionState.Error -> ConnectionStateDto(ConnectionStatusDto.ERROR, state.message)
KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING)
is KiloAppState.Loading -> KiloAppStateDto(
status = KiloAppStatusDto.LOADING,
progress = progress(state.progress),
)
is KiloAppState.Ready -> KiloAppStateDto(KiloAppStatusDto.READY)
is KiloAppState.Error -> KiloAppStateDto(
status = KiloAppStatusDto.ERROR,
error = state.message,
errors = state.errors.map(::error),
)
}
private fun progress(p: LoadProgress) = LoadProgressDto(
config = p.config,
notifications = p.notifications,
profile = when (p.profile) {
ProfileResult.PENDING -> ProfileStatusDto.PENDING
ProfileResult.LOADED -> ProfileStatusDto.LOADED
ProfileResult.NOT_LOGGED_IN -> ProfileStatusDto.NOT_LOGGED_IN
},
)
private fun error(e: LoadError) = LoadErrorDto(
resource = e.resource,
status = e.status,
detail = e.detail,
)
}
@@ -3,9 +3,9 @@
package ai.kilocode.client
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import fleet.rpc.client.durable
@@ -25,13 +25,13 @@ import kotlinx.coroutines.launch
* are app-scoped — no project context is needed.
*
* Callers of [watch] are responsible for scheduling UI updates on
* the EDT and converting [ConnectionStateDto] to display text.
* the EDT and converting [KiloAppStateDto] to display text.
*/
@Service(Service.Level.APP)
class KiloAppService(private val cs: CoroutineScope) {
companion object {
private val LOG = Logger.getInstance(KiloAppService::class.java)
private val init = ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
private val init = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
}
private val started = AtomicBoolean(false)
@@ -41,7 +41,7 @@ class KiloAppService(private val cs: CoroutineScope) {
var version: String? = null
private set
val state: StateFlow<ConnectionStateDto> = flow {
val state: StateFlow<KiloAppStateDto> = flow {
durable {
KiloAppRpcApi.getInstance()
.state()
@@ -111,15 +111,15 @@ class KiloAppService(private val cs: CoroutineScope) {
}
/**
* Collect connection state changes and invoke [fn] for each update.
* Collect app state changes and invoke [fn] for each update.
*
* The callback receives raw [ConnectionStateDto] — the caller is
* The callback receives raw [KiloAppStateDto] — the caller is
* responsible for converting to display text and scheduling on the EDT.
*/
fun watch(fn: (ConnectionStateDto) -> Unit): Job {
fun watch(fn: (KiloAppStateDto) -> Unit): Job {
return cs.launch {
state.collect { next ->
if (next.status == ConnectionStatusDto.CONNECTED) fetchVersionAsync()
if (next.status == KiloAppStatusDto.READY) fetchVersionAsync()
fn(next)
}
}
@@ -1,8 +1,11 @@
package ai.kilocode.client
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.LoadProgressDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.service
@@ -34,19 +37,32 @@ class KiloToolWindowFactory : ToolWindowFactory {
alignmentX = JPanel.CENTER_ALIGNMENT
}
val text = JBLabel(KiloBundle.message("toolwindow.status.disconnected"), SwingConstants.CENTER).apply {
val status = JBLabel(
KiloBundle.message("toolwindow.status.disconnected"),
SwingConstants.CENTER,
).apply {
alignmentX = JPanel.CENTER_ALIGNMENT
font = JBUI.Fonts.label(13f)
foreground = UIUtil.getContextHelpForeground()
setAllowAutoWrapping(true)
}
val detail = JBLabel("", SwingConstants.CENTER).apply {
alignmentX = JPanel.CENTER_ALIGNMENT
font = JBUI.Fonts.smallFont()
foreground = UIUtil.getContextHelpForeground()
setAllowAutoWrapping(true)
setCopyable(true)
}
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
add(icon)
add(Box.createVerticalStrut(JBUI.scale(16)))
add(text)
add(status)
add(Box.createVerticalStrut(JBUI.scale(6)))
add(detail)
}
val panel = JPanel(GridBagLayout()).apply {
@@ -58,7 +74,9 @@ class KiloToolWindowFactory : ToolWindowFactory {
val ui = Disposer.newDisposable()
val job = svc.watch { state ->
mgr.invokeLater {
text.text = format(state)
status.text = title(state)
detail.text = details(state)
detail.isVisible = detail.text.isNotEmpty()
}
}
Disposer.register(ui, Disposable { job.cancel() })
@@ -70,14 +88,73 @@ class KiloToolWindowFactory : ToolWindowFactory {
svc.connect()
}
private fun format(state: ConnectionStateDto): String =
private fun title(state: KiloAppStateDto): String =
when (state.status) {
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected")
ConnectionStatusDto.ERROR -> KiloBundle.message(
KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading")
KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected")
KiloAppStatusDto.ERROR -> KiloBundle.message(
"toolwindow.status.error",
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
)
}
private fun details(state: KiloAppStateDto): String =
when (state.status) {
KiloAppStatusDto.LOADING -> progress(state.progress)
KiloAppStatusDto.READY -> ready(state)
KiloAppStatusDto.ERROR -> errors(state)
else -> ""
}
private fun progress(p: LoadProgressDto?): String {
if (p == null) return ""
val lines = mutableListOf<String>()
lines.add(item("Config", p.config))
lines.add(item("Notifications", p.notifications))
lines.add(profile(p.profile))
return "<html>${lines.joinToString("<br>")}</html>"
}
private fun item(name: String, loaded: Boolean): String =
if (loaded) "$CHECK $name" else "$DOTS $name"
private fun profile(status: ProfileStatusDto): String =
when (status) {
ProfileStatusDto.PENDING -> "$DOTS Profile"
ProfileStatusDto.LOADED -> "$CHECK Profile"
ProfileStatusDto.NOT_LOGGED_IN -> "$DASH Profile (not logged in)"
}
private fun ready(state: KiloAppStateDto): String {
val svc = service<KiloAppService>()
val ver = svc.version
val lines = mutableListOf<String>()
if (ver != null) lines.add("CLI: $ver")
val p = state.progress
if (p != null && p.profile == ProfileStatusDto.NOT_LOGGED_IN) {
lines.add("Profile: not logged in")
}
return if (lines.isEmpty()) "" else "<html>${lines.joinToString("<br>")}</html>"
}
private fun errors(state: KiloAppStateDto): String {
if (state.errors.isEmpty()) return ""
val lines = state.errors.map(::formatError)
return "<html>Failed to load:<br>${lines.joinToString("<br>")}</html>"
}
private fun formatError(err: LoadErrorDto): String {
val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: ""
return if (suffix.isEmpty()) "$CROSS ${err.resource}"
else "$CROSS ${err.resource}: $suffix"
}
companion object {
private const val CHECK = "\u2713"
private const val CROSS = "\u2717"
private const val DOTS = "\u2026"
private const val DASH = "\u2013"
}
}
@@ -1,7 +1,7 @@
package ai.kilocode.client.actions
import ai.kilocode.client.KiloAppService
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
@@ -12,7 +12,7 @@ class ReinstallKiloAction : AnAction() {
}
override fun update(e: AnActionEvent) {
val state = service<KiloAppService>().state.value
e.presentation.isEnabled = state.status != ConnectionStatusDto.CONNECTING
val status = service<KiloAppService>().state.value.status
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
}
}
@@ -1,7 +1,7 @@
package ai.kilocode.client.actions
import ai.kilocode.client.KiloAppService
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
@@ -12,7 +12,7 @@ class RestartKiloAction : AnAction() {
}
override fun update(e: AnActionEvent) {
val state = service<KiloAppService>().state.value
e.presentation.isEnabled = state.status != ConnectionStatusDto.CONNECTING
val status = service<KiloAppService>().state.value.status
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
}
}
@@ -2,7 +2,7 @@ package ai.kilocode.client.actions
import ai.kilocode.client.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
@@ -19,10 +19,11 @@ class StatusInfoAction : AnAction() {
override fun update(e: AnActionEvent) {
val svc = service<KiloAppService>()
val status = when (svc.state.value.status) {
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected.short")
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
ConnectionStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected.short")
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading.short")
KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
KiloAppStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
}
val ver = svc.version?.let { " · $it" } ?: ""
e.presentation.text = "$status$ver"
@@ -1,11 +1,13 @@
toolwindow.status.disconnected=Status: Disconnected
toolwindow.status.connecting=Status: Connecting...
toolwindow.status.loading=Status: Loading...
toolwindow.status.connected=Status: Connected
toolwindow.status.error=Status: Error - {0}
toolwindow.error.unknown=Unknown error
toolwindow.status.connected.short=Connected
toolwindow.status.connecting.short=Connecting\u2026
toolwindow.status.loading.short=Loading\u2026
toolwindow.status.disconnected.short=Disconnected
toolwindow.status.error.short=Error
@@ -1,7 +1,7 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
@@ -25,8 +25,8 @@ interface KiloAppRpcApi : RemoteApi<Unit> {
/** Ensure the CLI backend is running and connected. */
suspend fun connect()
/** Observe connection state changes. */
suspend fun state(): Flow<ConnectionStateDto>
/** Observe app lifecycle state changes. */
suspend fun state(): Flow<KiloAppStateDto>
/** One-shot health check against /global/health. */
suspend fun health(): HealthDto
@@ -1,17 +0,0 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
enum class ConnectionStatusDto {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR,
}
@Serializable
data class ConnectionStateDto(
val status: ConnectionStatusDto,
val error: String? = null,
)
@@ -0,0 +1,41 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
enum class KiloAppStatusDto {
DISCONNECTED,
CONNECTING,
LOADING,
READY,
ERROR,
}
@Serializable
enum class ProfileStatusDto {
PENDING,
LOADED,
NOT_LOGGED_IN,
}
@Serializable
data class LoadProgressDto(
val config: Boolean = false,
val notifications: Boolean = false,
val profile: ProfileStatusDto = ProfileStatusDto.PENDING,
)
@Serializable
data class LoadErrorDto(
val resource: String,
val status: Int? = null,
val detail: String? = null,
)
@Serializable
data class KiloAppStateDto(
val status: KiloAppStatusDto,
val error: String? = null,
val errors: List<LoadErrorDto> = emptyList(),
val progress: LoadProgressDto? = null,
)