diff --git a/.changeset/jetbrains-advanced-logging.md b/.changeset/jetbrains-advanced-logging.md new file mode 100644 index 0000000000..946e7139d2 --- /dev/null +++ b/.changeset/jetbrains-advanced-logging.md @@ -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. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index 1a5726f941..e09e317d03 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index 70f85f8b83..bd07fe8da4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedLogActions.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedLogActions.kt new file mode 100644 index 0000000000..78b33c40c4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedLogActions.kt @@ -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() + 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 }) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedSettingsUi.kt index 912b9fc81a..47ecd2fc04 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/AdvancedSettingsUi.kt @@ -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 { + 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) + }, + ), + ) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index c5ff399f2d..64a79cc657 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/AdvancedConfigurableTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/AdvancedConfigurableTest.kt index e646bd9503..efcd664b88 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/AdvancedConfigurableTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/AdvancedConfigurableTest.kt @@ -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 = buildList { + collect(root) { if (it is ActionLink) add(it) } + } + private fun edt(block: () -> T): T = edtWait(block) private fun level(root: Container): ComboBox<*> = combos(root).single { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloLogSettingsServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloLogSettingsServiceTest.kt index 526b5fccfe..49b6bc71a9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloLogSettingsServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloLogSettingsServiceTest.kt @@ -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()) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 8ed656a5ee..a527e4e415 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -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) -> diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt index db01d2f458..3b1c8da367 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt @@ -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 = 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 diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index 981f7ffd5b..a579d6b0f5 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -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 { /** 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? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/LogFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/LogFileDto.kt new file mode 100644 index 0000000000..c29b161f7e --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/LogFileDto.kt @@ -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, +)