feat(jetbrains): add Logging section with log reveal and backend log download

Group the Advanced settings log level and message-preview controls under a
Logging section. Add a reveal action that opens the Kilo diagnostic log in the
OS file manager (Finder/Explorer/file manager). In remote development, the
client log is revealed locally and the remote backend log is fetched over RPC
and saved through a client-side dialog.
This commit is contained in:
kirillk
2026-08-19 19:38:58 -04:00
parent 5f2631934d
commit 3b8b18ff33
12 changed files with 212 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Add an Advanced settings page with a Logging section to configure diagnostic log level and message previews, reveal the log in your file manager, and download the backend log in remote development.
@@ -13,6 +13,7 @@ import ai.kilocode.backend.cli.KiloCliPlatform
import ai.kilocode.backend.cli.KiloProps
import ai.kilocode.backend.cli.KiloRepoCli
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.log.KiloLog
import ai.kilocode.log.LogConfig
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.KiloAppRpcApi
@@ -24,6 +25,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.LoadProgressDto
import ai.kilocode.rpc.dto.LogConfigDto
import ai.kilocode.rpc.dto.LogFileDto
import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto
import ai.kilocode.rpc.dto.ModelSelectionUpdateDto
import ai.kilocode.rpc.dto.ModelStateDto
@@ -35,9 +37,12 @@ import ai.kilocode.rpc.dto.ProfileOrganizationDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import ai.kilocode.rpc.dto.TelemetryCaptureDto
import com.intellij.openapi.components.service
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.nio.file.Files
/**
* Backend implementation of [KiloAppRpcApi].
@@ -102,6 +107,12 @@ class KiloAppRpcApiImpl : KiloAppRpcApi {
LogConfig.apply(config.level, config.contentMode, config.previewMax)
}
override suspend fun backendLogFile(): LogFileDto? = withContext(Dispatchers.IO) {
val path = KiloLog.logFile()
if (!Files.exists(path)) return@withContext null
LogFileDto(path.fileName.toString(), Files.readString(path))
}
override suspend fun refreshProfile(): ProfileDto? = app.refreshProfile()?.let(::profileDto)
override suspend fun startLogin(directory: String?): DeviceAuthDto = app.startLogin(directory)
@@ -9,6 +9,7 @@ import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LogConfigDto
import ai.kilocode.rpc.dto.LogFileDto
import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelSelectionUpdateDto
@@ -116,6 +117,14 @@ class KiloAppService internal constructor(
}
}
/** Read the backend diagnostic log file for download in split mode. Null when absent or on failure. */
suspend fun backendLog(): LogFileDto? = try {
call { backendLogFile() }
} catch (e: Exception) {
LOG.warn("backend log fetch failed", e)
null
}
/** One-shot health check. Returns null on failure. */
suspend fun health(): HealthDto? = try {
call { health() }
@@ -0,0 +1,85 @@
package ai.kilocode.client.settings
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.LogFileDto
import com.intellij.ide.actions.RevealFileAction
import com.intellij.notification.Notification
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
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.openapi.fileChooser.FileChooserFactory
import com.intellij.openapi.fileChooser.FileSaverDescriptor
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.vfs.VirtualFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.nio.file.Files
import javax.swing.JComponent
/**
* Diagnostic log actions for the Advanced settings page.
*
* In monolith mode the frontend and backend share one process, so a single reveal opens the log
* in the OS file manager. In split mode the backend log lives on the remote host, so it is fetched
* over RPC and saved through a client-side file dialog while the client log is revealed locally.
*/
internal object AdvancedLogActions {
/** OS-appropriate reveal label (e.g. "Reveal in Finder", "Show in Explorer"). */
fun revealLabel(): String = RevealFileAction.getActionName()
fun reveal() {
ApplicationManager.getApplication().executeOnPooledThread {
val path = KiloLog.logFile()
if (Files.exists(path)) RevealFileAction.openFile(path)
else RevealFileAction.openDirectory(path.parent)
}
}
fun downloadBackend(parent: JComponent) {
val app = service<KiloAppService>()
app.scope.launch {
val log = app.backendLog()
withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) {
save(parent, log)
}
}
}
private fun save(parent: JComponent, log: LogFileDto?) {
if (log == null) {
notify(NotificationType.WARNING, KiloBundle.message("settings.advanced.logs.backend.missing"))
return
}
val descriptor = FileSaverDescriptor(
KiloBundle.message("settings.advanced.logs.backend.save.title"),
KiloBundle.message("settings.advanced.logs.backend.save.description"),
"log",
)
val wrapper = FileChooserFactory.getInstance()
.createSaveFileDialog(descriptor, parent)
.save(null as VirtualFile?, log.name) ?: return
ApplicationManager.getApplication().executeOnPooledThread {
runCatching { wrapper.file.writeText(log.content, Charsets.UTF_8) }
.onSuccess { notify(NotificationType.INFORMATION, KiloBundle.message("settings.advanced.logs.backend.saved", wrapper.file.name)) }
.onFailure { notify(NotificationType.ERROR, KiloBundle.message("settings.advanced.logs.backend.failed"), it.message) }
}
}
private fun notify(type: NotificationType, title: String, content: String? = null) {
ApplicationManager.getApplication().invokeLater {
val notification = NotificationGroupManager.getInstance()
.getNotificationGroup("Kilo Code")
?.createNotification(title, content.orEmpty(), type)
?: Notification("Kilo Code", title, content.orEmpty(), type)
notification.notify(ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault })
}
}
}
@@ -6,6 +6,9 @@ import ai.kilocode.client.settings.base.SettingsRows
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.log.LogConfig
import com.intellij.openapi.ui.ComboBox
import com.intellij.platform.ide.productMode.IdeProductMode
import com.intellij.ui.TitledSeparator
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
@@ -30,6 +33,7 @@ internal class AdvancedSettingsUi : JPanel(BorderLayout()) {
val rows = SettingsRows().apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.lg())
row(TitledSeparator(KiloBundle.message("settings.advanced.logging.title")))
row(SettingsRow(
KiloBundle.message("logs.configuration.level.title"),
KiloBundle.message("logs.configuration.level.description"),
@@ -45,6 +49,7 @@ internal class AdvancedSettingsUi : JPanel(BorderLayout()) {
KiloBundle.message("logs.configuration.previewSize.description", LogConfig.MIN_PREVIEW, LogConfig.MAX_PREVIEW),
preview,
))
logRows().forEach(::row)
}
add(rows, BorderLayout.CENTER)
}
@@ -83,4 +88,30 @@ internal class AdvancedSettingsUi : JPanel(BorderLayout()) {
private fun current(): Values = Values(LogConfig.level(), LogConfig.contentMode(), LogConfig.previewMax())
private fun count(): Int? = preview.text.trim().toIntOrNull()
// In monolith mode one reveal opens the shared log; in split mode the client log is revealed
// locally and the remote backend log is downloaded.
private fun logRows(): List<SettingsRow> {
if (IdeProductMode.isMonolith) {
return listOf(SettingsRow(
KiloBundle.message("settings.advanced.logs.title"),
KiloBundle.message("settings.advanced.logs.description"),
ActionLink(AdvancedLogActions.revealLabel()) { AdvancedLogActions.reveal() },
))
}
return listOf(
SettingsRow(
KiloBundle.message("settings.advanced.logs.client.title"),
KiloBundle.message("settings.advanced.logs.client.description"),
ActionLink(AdvancedLogActions.revealLabel()) { AdvancedLogActions.reveal() },
),
SettingsRow(
KiloBundle.message("settings.advanced.logs.backend.title"),
KiloBundle.message("settings.advanced.logs.backend.description"),
ActionLink(KiloBundle.message("settings.advanced.logs.backend.download")) {
AdvancedLogActions.downloadBackend(this)
},
),
)
}
}
@@ -428,6 +428,19 @@ settings.cli.unavailable.message=Settings are available after Kilo Code connects
settings.models.displayName=Models
settings.context.displayName=Context
settings.advanced.displayName=Advanced
settings.advanced.logging.title=Logging
settings.advanced.logs.title=Diagnostic logs
settings.advanced.logs.description=Open the Kilo diagnostic log in your file manager.
settings.advanced.logs.client.title=Client log
settings.advanced.logs.client.description=Open the JetBrains client diagnostic log in your file manager.
settings.advanced.logs.backend.title=Backend log
settings.advanced.logs.backend.description=Download the remote backend diagnostic log.
settings.advanced.logs.backend.download=Download\u2026
settings.advanced.logs.backend.missing=No backend diagnostic log is available yet.
settings.advanced.logs.backend.save.title=Save Backend Log
settings.advanced.logs.backend.save.description=Choose where to save the backend diagnostic log.
settings.advanced.logs.backend.saved=Saved backend log to {0}
settings.advanced.logs.backend.failed=Failed to save backend log
settings.context.description=Configure context behavior.
settings.context.save.pending=Saving context settings...
settings.context.save.failed=Failed to save context settings
@@ -5,6 +5,7 @@ import ai.kilocode.log.LogConfig
import com.intellij.openapi.options.ConfigurationException
import com.intellij.openapi.ui.ComboBox
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBTextField
import java.awt.Container
import javax.swing.JComponent
@@ -91,8 +92,22 @@ class AdvancedConfigurableTest : BasePlatformTestCase() {
}
}
fun `test createComponent shows a reveal logs action`() {
// Tests run in monolith mode, so a single OS-appropriate reveal link is shown.
val cfg = configurable()
edt {
val root = cfg.createComponent()
val labels = links(root as Container).map { it.text }
assertTrue("expected a reveal-logs link, got $labels", labels.contains(AdvancedLogActions.revealLabel()))
}
}
private fun configurable() = AdvancedConfigurable(settings) { it.applyLocal() }
private fun links(root: Container): List<ActionLink> = buildList {
collect(root) { if (it is ActionLink) add(it) }
}
private fun <T> edt(block: () -> T): T = edtWait(block)
private fun level(root: Container): ComboBox<*> = combos(root).single {
@@ -3,6 +3,7 @@ package ai.kilocode.client.settings
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.log.LogConfig
import ai.kilocode.rpc.dto.LogFileDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -53,4 +54,19 @@ class KiloLogSettingsServiceTest : BasePlatformTestCase() {
assertEquals("PREVIEW", dto.contentMode)
assertEquals(25, dto.previewMax)
}
fun `test backendLog returns the file from rpc`() = runBlocking(Dispatchers.Default) {
rpc.backendLog = LogFileDto("kilo.log", "line one\nline two\n")
val log = app.backendLog()
assertEquals("kilo.log", log?.name)
assertEquals("line one\nline two\n", log?.content)
}
fun `test backendLog returns null when backend has no log`() = runBlocking(Dispatchers.Default) {
rpc.backendLog = null
assertNull(app.backendLog())
}
}
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LogConfigDto
import ai.kilocode.rpc.dto.LogFileDto
import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelSelectionUpdateDto
@@ -178,6 +179,13 @@ class FakeAppRpcApi : KiloAppRpcApi {
logConfigs.add(config)
}
var backendLog: LogFileDto? = null
override suspend fun backendLogFile(): LogFileDto? {
assertNotEdt("backendLogFile")
return backendLog
}
private fun applyPatch(config: ConfigDto, patch: ConfigPatchDto): ConfigDto {
val values = patch.values
val agents = patch.agents.entries.fold(config.agent) { acc, (name, item) ->
@@ -67,6 +67,9 @@ interface KiloLog {
fun sandbox(): Boolean = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean()
/** Current diagnostic log file for this process (frontend or backend in split mode). */
fun logFile(): Path = FileLog.logFile()
fun payload(log: KiloLog? = null): Map<String, String> = buildMap {
put("platform", "jetbrains")
put("client", "jetbrains")
@@ -166,6 +169,8 @@ internal class FileLog(cls: Class<*>) : KiloLog {
if (!initialized) return
root.level = LogConfig.julLevel()
}
internal fun logFile(): Path = resolveLogDir().resolve("kilo.log")
}
override val isDebugEnabled: Boolean
@@ -5,6 +5,7 @@ import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.LogConfigDto
import ai.kilocode.rpc.dto.LogFileDto
import ai.kilocode.rpc.dto.ModelFavoriteUpdateDto
import ai.kilocode.rpc.dto.ModelSelectionUpdateDto
import ai.kilocode.rpc.dto.ModelStateDto
@@ -79,6 +80,9 @@ interface KiloAppRpcApi : RemoteApi<Unit> {
/** Apply frontend-managed diagnostic log settings in the backend process. */
suspend fun applyLogConfig(config: LogConfigDto)
/** Read the backend diagnostic log file for download in split mode. Null when absent. */
suspend fun backendLogFile(): LogFileDto?
/** Refresh the user profile and return the latest data, or null if not logged in. */
suspend fun refreshProfile(): ProfileDto?
@@ -0,0 +1,10 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
/** Backend diagnostic log file contents, transferred to the frontend for download in split mode. */
@Serializable
data class LogFileDto(
val name: String,
val content: String,
)