Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-config-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show resolved JetBrains config file paths, float connection status above the prompt, and offer retry, restart, and reinstall recovery actions from connection errors. JetBrains now opens the same global config directory used by the CLI; macOS and Windows users who previously created global config from JetBrains may need to move files from the old platform-specific location to `~/.config/kilo`.
2 changes: 1 addition & 1 deletion packages/kilo-jetbrains/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- Extend `BasePlatformTestCase` to get a real IntelliJ Application and EDT in tests. The session package already uses `SessionControllerTestBase` which wraps this.
- Do not mock the EDT or threading assertions — test against the real threading model.
- Do not add production methods whose only purpose is test access. Prefer exercising the public API and inspecting the real Swing component tree in tests.
- Do not expose `internal` accessors, helper methods, or synthetic seams just so tests can inspect private implementation details. If a test needs this, either assert observable UI/action behavior or refactor the production API so the new seam has real product value.
- For state-driven updates, assert that the component state matches after flushing coroutines and draining the EDT.
- For retained Swing components, assert that expand/collapse, update, and no-op paths work correctly without rebuilding the component tree.

Expand Down Expand Up @@ -240,7 +241,6 @@ Before introducing any new reusable color, spacing value, border, size, font, or
- `SessionUiStyle.View` — card sizing, card borders, surfaces, hover colors, and nested objects for `Prompt`, `Reasoning`, `Message`, and `Tool`.
- `SessionUiStyle.RecentSessions` — recent sessions list limits.
- `SessionUiStyle.Timeline` — activity-indicator colors for the session header timeline.
- `Dock` — border presets for question, permission, and connection dock panels.

Rules:
- Generic layout constants (gaps, generic colors, reusable helpers) → `UiStyle`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
package ai.kilocode.backend.cli

import com.intellij.openapi.util.SystemInfo
import java.io.File

internal object KiloCliConfigPath {
private const val APP = "kilo"

fun resolve(env: Map<String, String>): File {
env["KILO_CONFIG_DIR"]?.takeIf { it.isNotBlank() }?.let { return File(it) }
env["XDG_CONFIG_HOME"]?.takeIf { it.isNotBlank() }?.let { return File(it, "kilo") }
return File(defaultRoot(), "kilo")
env["XDG_CONFIG_HOME"]?.takeIf { it.isNotBlank() }?.let { return File(it, APP) }
return File(File(home(env), ".config"), APP)
Comment thread
kirillk marked this conversation as resolved.
}

fun legacySettingsFile(env: Map<String, String>): File = File(resolve(env), "legacy-settings.json")

private fun defaultRoot(): File {
if (SystemInfo.isWindows) {
val app = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
if (app != null) return File(app)
}
if (SystemInfo.isMac) return File(System.getProperty("user.home"), "Library/Application Support")
return File(System.getProperty("user.home"), ".config")
private fun home(env: Map<String, String>): String {
return env["HOME"]?.takeIf { it.isNotBlank() }
?: env["USERPROFILE"]?.takeIf { it.isNotBlank() }
?: System.getProperty("user.home")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package ai.kilocode.backend.cli

import kotlin.test.Test
import kotlin.test.assertEquals
import java.io.File
import java.nio.file.Files

class KiloCliConfigPathTest {

@Test
fun `kilo config dir overrides XDG config home`() {
val dir = Files.createTempDirectory("kilo-config-dir").toFile()
val xdg = Files.createTempDirectory("kilo-xdg-config").toFile()

val path = KiloCliConfigPath.resolve(
mapOf(
"KILO_CONFIG_DIR" to dir.absolutePath,
"XDG_CONFIG_HOME" to xdg.absolutePath,
),
)

assertEquals(dir.absoluteFile, path.absoluteFile)
}

@Test
fun `XDG config home resolves to kilo subdirectory`() {
val xdg = Files.createTempDirectory("kilo-xdg-config").toFile()

val path = KiloCliConfigPath.resolve(mapOf("XDG_CONFIG_HOME" to xdg.absolutePath))

assertEquals(File(xdg, "kilo").absoluteFile, path.absoluteFile)
}

@Test
fun `default config home matches CLI xdg fallback`() {
val home = Files.createTempDirectory("kilo-home").toFile()

val path = KiloCliConfigPath.resolve(mapOf("HOME" to home.absolutePath))

assertEquals(File(File(home, ".config"), "kilo").absoluteFile, path.absoluteFile)
}

@Test
fun `USERPROFILE backs up HOME for default config home`() {
val home = Files.createTempDirectory("kilo-userprofile").toFile()

val path = KiloCliConfigPath.resolve(
mapOf(
"HOME" to "",
"USERPROFILE" to home.absolutePath,
),
)

assertEquals(File(File(home, ".config"), "kilo").absoluteFile, path.absoluteFile)
}

@Test
fun `blank config env values are ignored`() {
val home = Files.createTempDirectory("kilo-home").toFile()

val path = KiloCliConfigPath.resolve(
mapOf(
"KILO_CONFIG_DIR" to " ",
"XDG_CONFIG_HOME" to "",
"HOME" to home.absolutePath,
),
)

assertEquals(File(File(home, ".config"), "kilo").absoluteFile, path.absoluteFile)
}

@Test
fun `legacy settings file resolves under global config dir`() {
val home = Files.createTempDirectory("kilo-home").toFile()

val path = KiloCliConfigPath.legacySettingsFile(mapOf("HOME" to home.absolutePath))

assertEquals(File(File(File(home, ".config"), "kilo"), "legacy-settings.json").absoluteFile, path.absoluteFile)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package ai.kilocode.client.actions

import ai.kilocode.client.session.SessionManager
import com.intellij.openapi.actionSystem.AnActionEvent

internal fun AnActionEvent.workspaceDirectory(): String? {
return getData(SessionManager.WORKSPACE_KEY)?.directory ?: project?.basePath
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package ai.kilocode.client.actions

import com.intellij.openapi.actionSystem.ActionPlaces

internal object KiloActionPlaces {
const val CONNECTION_RETRY = "Kilo.ConnectionRetry"

fun connectionRetryPopup() = ActionPlaces.getActionGroupPopupPlace(CONNECTION_RETRY)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package ai.kilocode.client.actions

import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.telemetry.Telemetry
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionGroupUtil
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import com.intellij.openapi.ui.popup.JBPopupFactory
import kotlinx.coroutines.Job

/**
* Gear icon action placed in the Kilo tool window title bar.
Expand All @@ -18,21 +22,35 @@ class KiloSettingsAction : AnAction() {

companion object {
const val GROUP_ID = "Kilo.SettingsGroup"

internal fun popupGroup(group: ActionGroup): ActionGroup {
return ActionGroupUtil.forceRecursiveUpdateInBackground(group)
}

internal fun refreshConfigTargets(e: AnActionEvent, service: KiloWorkspaceService): List<Job> {
return listOfNotNull(
e.workspaceDirectory()?.let { service.refreshLocalConfigTarget(it) },
service.refreshGlobalConfigTarget(),
)
}
}

override fun actionPerformed(e: AnActionEvent) {
val component = e.inputEvent?.component ?: return
val group = ActionManager.getInstance().getAction(GROUP_ID) as? ActionGroup ?: return
val service = service<KiloWorkspaceService>()
refreshConfigTargets(e, service)
Telemetry.send("Settings Opened", mapOf("surface" to "tool_window"))

JBPopupFactory.getInstance()
.createActionGroupPopup(
null,
group,
popupGroup(group),
e.dataContext,
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
true,
)
.showUnderneathOf(component)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package ai.kilocode.client.actions
import ai.kilocode.client.KiloNotifications
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.rpc.dto.ConfigTargetDto
import com.intellij.openapi.actionSystem.ActionUpdateThread
Expand Down Expand Up @@ -37,22 +36,24 @@ class OpenLocalConfigAction : ConfigAction(
description = KiloBundle.message("action.Kilo.OpenLocalConfig.description"),
) {
override fun update(e: AnActionEvent) {
val dir = directory(e)
val dir = e.workspaceDirectory()
val service = service<KiloWorkspaceService>()
val target = dir?.let { service.localConfig[it] }
e.presentation.isEnabled = dir != null
e.presentation.text = text(dir?.let { service<KiloWorkspaceService>().localConfig[it] })
e.presentation.text = text(target)

if (dir != null && target == null) {
service.refreshLocalConfigTarget(dir)
}
}

override fun actionPerformed(e: AnActionEvent) {
val dir = directory(e) ?: return
val dir = e.workspaceDirectory() ?: return
Telemetry.send("Config Opened", mapOf("surface" to "tool_window", "scope" to "local"))
service<KiloWorkspaceService>().openLocalConfig(dir) { ok ->
if (!ok) failed()
}
}

private fun directory(e: AnActionEvent): String? {
return e.getData(SessionManager.WORKSPACE_KEY)?.directory ?: e.project?.basePath
}
}

class OpenGlobalConfigAction : ConfigAction(
Expand All @@ -62,7 +63,13 @@ class OpenGlobalConfigAction : ConfigAction(
description = KiloBundle.message("action.Kilo.OpenGlobalConfig.description"),
) {
override fun update(e: AnActionEvent) {
e.presentation.text = text(service<KiloWorkspaceService>().globalConfig)
val service = service<KiloWorkspaceService>()
val target = service.globalConfig
e.presentation.text = text(target)

if (target == null) {
service.refreshGlobalConfigTarget()
}
}

override fun actionPerformed(e: AnActionEvent) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.kilocode.client.actions

import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
Expand All @@ -15,5 +16,8 @@ class ReinstallKiloAction : AnAction(), DumbAware {

override fun update(e: AnActionEvent) {
e.presentation.isEnabled = true
if (e.place == KiloActionPlaces.connectionRetryPopup()) {
e.presentation.text = KiloBundle.message("action.Kilo.Reinstall.cli.text")
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ai.kilocode.client.actions

import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.telemetry.Telemetry
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
Expand All @@ -15,5 +16,8 @@ class RestartKiloAction : AnAction(), DumbAware {

override fun update(e: AnActionEvent) {
e.presentation.isEnabled = true
if (e.place == KiloActionPlaces.connectionRetryPopup()) {
e.presentation.text = KiloBundle.message("action.Kilo.Restart.cli.text")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.ide.ActivityTracker
import com.intellij.openapi.components.Service
import ai.kilocode.log.KiloLog
import com.intellij.platform.project.ProjectId
import fleet.rpc.client.durable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean

/**
* App-level service that manages [Workspace] instances keyed by directory.
Expand All @@ -46,6 +49,8 @@ class KiloWorkspaceService internal constructor(

private val workspaces = ConcurrentHashMap<String, Workspace>()
internal val localConfig = ConcurrentHashMap<String, ConfigTargetDto>()
private val pendingLocal = ConcurrentHashMap.newKeySet<String>()
private val pendingGlobal = AtomicBoolean(false)

@Volatile
internal var globalConfig: ConfigTargetDto? = null
Expand Down Expand Up @@ -74,12 +79,16 @@ class KiloWorkspaceService internal constructor(
* for the same directory share the same instance.
*/
fun workspace(directory: String): Workspace {
return workspaces.getOrPut(directory) {
val workspace = workspaces.getOrPut(directory) {
LOG.info("Creating workspace for $directory")
val state = stream { state(directory) }
.stateIn(cs, SharingStarted.Eagerly, INIT)
Workspace(directory, state) { reload(directory) }
}
// Refresh on every workspace access so config actions reflect file system changes.
refreshLocalConfigTarget(directory)
refreshGlobalConfigTarget()
return workspace
}

/**
Expand Down Expand Up @@ -181,6 +190,32 @@ class KiloWorkspaceService internal constructor(
}
}

fun refreshLocalConfigTarget(directory: String): Job? {
if (!pendingLocal.add(directory)) return null

return cs.launch {
try {
localConfigTarget(directory)
} finally {
pendingLocal.remove(directory)
ActivityTracker.getInstance().inc()
}
}
}

fun refreshGlobalConfigTarget(): Job? {
if (!pendingGlobal.compareAndSet(false, true)) return null

return cs.launch {
try {
globalConfigTarget()
} finally {
pendingGlobal.set(false)
ActivityTracker.getInstance().inc()
}
}
}

fun openLocalConfig(directory: String, done: (Boolean) -> Unit) {
cs.launch {
val ok = try {
Expand Down
Loading
Loading