mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -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() {
|
||||
|
||||
+12
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
+31
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user