wip(jetbrains): add settings gear popup, fix boolean const deserialization, add logging

- Settings gear icon in tool window title bar shows popup with Restart/Reinstall
  actions and a connection status + version info line
- Actions declared in frontend XML with IDs, looked up via ActionManager
- Fix Moshi deserialization of const boolean fields: openapi-generator creates
  enum classes for `const: true` but Moshi expects strings while the server
  sends JSON booleans — fixGeneratedApi now replaces these with plain Boolean
- Add detailed logging across restart/reinstall/teardown/health chains
This commit is contained in:
kirillk
2026-04-09 18:01:10 -04:00
parent b937e4d6e6
commit 30009f4bca
11 changed files with 245 additions and 18 deletions
@@ -55,27 +55,48 @@ openApiGenerate {
}
// Fix openapi-generator 3.1.1 codegen bugs in generated Kotlin sources.
// - Boolean const enums: `enum class Foo(val value: kotlin.Boolean) { TRUE("true") }` → fix string→boolean
//
// The OpenAPI spec uses `const: true` on boolean fields (e.g. `healthy`).
// openapi-generator turns these into single-value enum classes:
//
// val healthy: GlobalHealth200Response.Healthy
// enum class Healthy(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") }
//
// Moshi's EnumJsonAdapter calls nextString() for the value, but the server sends
// a JSON boolean `true`, not a JSON string `"true"`, causing:
// JsonDataException: Expected a string but was BOOLEAN at path $.healthy
//
// Fix: replace the enum field type with kotlin.Boolean, remove the enum class.
val fixGeneratedApi by tasks.registering {
dependsOn("openApiGenerate")
val dir = generatedApi
doLast {
// Regex to find boolean const enum declarations inside data classes.
// Captures the enum name so we can find and fix the corresponding field.
val enumDecl = Regex(
"""enum class (\w+)\(val value: kotlin\.Boolean\)"""
)
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
var changed = false
// Fix: enum Xxx(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") }
// → enum Xxx(val value: kotlin.Boolean) { @Json(name = "true") TRUE(true) }
val boolEnum = Regex(
"""(enum class \w+\(val value: kotlin\.Boolean\) \{[^}]*?@Json\(name = ")(true|false)("\) \w+\()"(true|false)"(\))"""
)
val replaced = boolEnum.replace(text) { m ->
"${m.groupValues[1]}${m.groupValues[2]}${m.groupValues[3]}${m.groupValues[4]}${m.groupValues[5]}"
val names = enumDecl.findAll(text).map { it.groupValues[1] }.toList()
if (names.isEmpty()) return@forEach
for (name in names) {
// Replace field type: `val foo: EnclosingClass.EnumName` → `val foo: kotlin.Boolean`
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
"${m.groupValues[1]}kotlin.Boolean"
}
// Remove the @JsonClass annotation + enum class block
text = text.replace(Regex(
"""\n\s*@JsonClass\(generateAdapter = false\)\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
), "")
// Remove the orphaned KDoc block that preceded the enum (lines of ` *` ending with `*/`)
// These look like: \n /**\n * \n *\n * Values: TRUE\n */
text = text.replace(Regex(
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
), "")
}
if (replaced != text) {
text = replaced
changed = true
}
if (changed) file.writeText(text)
file.writeText(text)
}
}
}
@@ -92,17 +92,22 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
/** Kill the CLI process and restart it. Tears down all connections first. */
suspend fun restart() {
LOG.info("Restarting CLI")
LOG.info("restart: initiated — tearing down current connection")
teardown()
LOG.info("restart: teardown complete — spawning new CLI process")
open()
LOG.info("restart: open() returned — CLI process started")
}
/** Kill the CLI process, re-extract the binary from JAR, and restart. */
suspend fun reinstall() {
LOG.info("Reinstalling CLI")
LOG.info("reinstall: initiated — tearing down current connection")
teardown()
LOG.info("reinstall: teardown complete — setting forceExtract flag")
service<ServerManager>().forceExtract = true
LOG.info("reinstall: spawning new CLI process (binary will be re-extracted)")
open()
LOG.info("reinstall: open() returned — CLI process started with fresh binary")
}
/**
@@ -112,14 +117,19 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
* cannot race with the SSE close or process kill.
*/
private suspend fun teardown() {
LOG.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
reconnectJob?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
LOG.info("teardown: closing SSE event source")
source.getAndSet(null)?.cancel()
LOG.info("teardown: shutting down OkHttp clients")
close()
setState(ConnectionState.Disconnected)
LOG.info("teardown: killing CLI process via ServerManager.stop()")
service<ServerManager>().stop()
LOG.info("teardown: complete")
}
private suspend fun open() {
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.Project
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@@ -25,6 +26,10 @@ class KiloProjectService(
private val project: Project,
private val cs: CoroutineScope,
) {
companion object {
private val LOG = Logger.getInstance(KiloProjectService::class.java)
}
private val connection: KiloConnectionService
get() = service()
@@ -62,8 +67,14 @@ class KiloProjectService(
* Returns [HealthDto] or throws if not connected / server unreachable.
*/
suspend fun health(): HealthDto {
val client = api ?: throw IllegalStateException("Not connected")
val client = api
if (client == null) {
LOG.warn("health: API client is null — not connected")
throw IllegalStateException("Not connected")
}
LOG.info("health: calling /global/health")
val response = client.globalHealth()
LOG.info("health: version=${response.version}")
return HealthDto(healthy = true, version = response.version)
}
}
@@ -7,6 +7,7 @@ import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.platform.project.projectId
@@ -33,11 +34,17 @@ class KiloApiService(
private val cs: CoroutineScope,
) {
companion object {
private val LOG = Logger.getInstance(KiloApiService::class.java)
private val init = ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
}
private val started = AtomicBoolean(false)
/** CLI version string from the last successful health check, or null if unknown. */
@Volatile
var version: String? = null
private set
val state: StateFlow<ConnectionStateDto> = flow {
durable {
KiloProjectRpcApi.getInstance()
@@ -58,26 +65,61 @@ class KiloApiService(
/** One-shot health check. Returns null on failure. */
suspend fun health(): HealthDto? = try {
durable { KiloProjectRpcApi.getInstance().health(project.projectId()) }
} catch (_: Exception) {
} catch (e: Exception) {
LOG.warn("health check failed", e)
null
}
/** Kill the CLI process and restart it. */
suspend fun restart() {
LOG.info("restart: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloProjectRpcApi.getInstance().restart(project.projectId()) }
LOG.info("restart: RPC returned — backend restart complete")
}
/** Kill the CLI process, re-extract the binary, and restart. */
suspend fun reinstall() {
LOG.info("reinstall: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloProjectRpcApi.getInstance().reinstall(project.projectId()) }
LOG.info("reinstall: RPC returned — backend reinstall complete")
}
/** Fire-and-forget restart from non-suspend context (e.g. action handlers). */
fun restartAsync() {
LOG.info("restartAsync: launching restart")
cs.launch { restart() }
}
/** Fire-and-forget reinstall from non-suspend context (e.g. action handlers). */
fun reinstallAsync() {
LOG.info("reinstallAsync: launching reinstall")
cs.launch { reinstall() }
}
/** Fetch the CLI version and cache it. Call once after connection is established. */
fun fetchVersionAsync() {
cs.launch {
LOG.info("fetchVersion: requesting health check")
val dto = health()
if (dto == null) {
LOG.warn("fetchVersion: health check returned null — version not available")
return@launch
}
version = dto.version
LOG.info("fetchVersion: CLI version is ${dto.version}")
}
}
fun watch(fn: (String) -> Unit): Job {
val mgr = ToolWindowManager.getInstance(project)
return cs.launch {
state.collect { next ->
// Fetch CLI version when we become connected
if (next.status == ConnectionStatusDto.CONNECTED) fetchVersionAsync()
mgr.invokeLater {
fn(text(next))
}
@@ -1,6 +1,7 @@
package ai.kilocode
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
@@ -56,6 +57,9 @@ class KiloToolWindowFactory : ToolWindowFactory {
Disposer.register(ui, Disposable { job.cancel() })
content.setDisposer(ui)
toolWindow.contentManager.addContent(content)
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
toolWindow.setTitleActions(listOf(it))
}
svc.connect()
}
}
@@ -0,0 +1,36 @@
package ai.kilocode.actions
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.popup.JBPopupFactory
/**
* Gear icon action placed in the Kilo tool window title bar.
*
* Looks up [Kilo.SettingsGroup] from [ActionManager] and shows it
* as a popup. The group composition is declared in
* `kilo.jetbrains.frontend.xml`.
*/
class KiloSettingsAction : AnAction() {
companion object {
const val GROUP_ID = "Kilo.SettingsGroup"
}
override fun actionPerformed(e: AnActionEvent) {
val component = e.inputEvent?.component ?: return
val group = ActionManager.getInstance().getAction(GROUP_ID) as? ActionGroup ?: return
JBPopupFactory.getInstance()
.createActionGroupPopup(
null,
group,
e.dataContext,
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
true, // showDisabledActions — StatusInfoAction is always disabled
)
.showUnderneathOf(component)
}
}
@@ -0,0 +1,18 @@
package ai.kilocode.actions
import ai.kilocode.KiloApiService
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
class ReinstallKiloAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
e.project?.service<KiloApiService>()?.reinstallAsync()
}
override fun update(e: AnActionEvent) {
val state = e.project?.service<KiloApiService>()?.state?.value
e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING
}
}
@@ -0,0 +1,18 @@
package ai.kilocode.actions
import ai.kilocode.KiloApiService
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
class RestartKiloAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
e.project?.service<KiloApiService>()?.restartAsync()
}
override fun update(e: AnActionEvent) {
val state = e.project?.service<KiloApiService>()?.state?.value
e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING
}
}
@@ -0,0 +1,31 @@
package ai.kilocode.actions
import ai.kilocode.KiloApiService
import ai.kilocode.KiloBundle
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
/**
* Non-interactive info row at the bottom of the settings popup showing
* connection status and CLI version (from the last health check).
*/
class StatusInfoAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
// intentionally non-actionable
}
override fun update(e: AnActionEvent) {
val svc = e.project?.service<KiloApiService>() ?: return
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")
}
val ver = svc.version?.let { " · $it" } ?: ""
e.presentation.text = "$status$ver"
e.presentation.isEnabled = false
}
}
@@ -12,4 +12,26 @@
icon="/icons/kilo.svg"
factoryClass="ai.kilocode.KiloToolWindowFactory"/>
</extensions>
<actions>
<action id="Kilo.Restart"
class="ai.kilocode.actions.RestartKiloAction"/>
<action id="Kilo.Reinstall"
class="ai.kilocode.actions.ReinstallKiloAction"/>
<action id="Kilo.StatusInfo"
class="ai.kilocode.actions.StatusInfoAction"/>
<group id="Kilo.SettingsGroup">
<reference ref="Kilo.Restart"/>
<reference ref="Kilo.Reinstall"/>
<separator/>
<reference ref="Kilo.StatusInfo"/>
</group>
<action id="Kilo.Settings"
class="ai.kilocode.actions.KiloSettingsAction"
icon="AllIcons.General.GearPlain"/>
</actions>
</idea-plugin>
@@ -3,3 +3,17 @@ toolwindow.status.connecting=Status: Connecting...
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.disconnected.short=Disconnected
toolwindow.status.error.short=Error
action.Kilo.Settings.text=Settings
action.Kilo.Settings.description=Kilo Code settings
action.Kilo.SettingsGroup.text=Settings
action.Kilo.SettingsGroup.description=Kilo Code settings
action.Kilo.Restart.text=Restart Kilo
action.Kilo.Restart.description=Kill and restart the CLI process
action.Kilo.Reinstall.text=Reinstall Kilo
action.Kilo.Reinstall.description=Re-extract the CLI binary and restart