From c08302b47a4fc68e4efda8f722e87e3018601bd8 Mon Sep 17 00:00:00 2001 From: jhapate0704 Date: Fri, 3 Jul 2026 22:58:28 +0530 Subject: [PATCH 01/33] fix(vscode): reset scroll position on diff change Fixes #10231 --- .changeset/reset-diff-scroll.md | 5 +++++ .../webview-ui/diff-virtual/DiffVirtualApp.tsx | 17 +++++++++++++++-- .../src/components/chat/PermissionDiff.tsx | 18 ++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 .changeset/reset-diff-scroll.md diff --git a/.changeset/reset-diff-scroll.md b/.changeset/reset-diff-scroll.md new file mode 100644 index 0000000000..c5efe834f8 --- /dev/null +++ b/.changeset/reset-diff-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix scroll position not resetting when switching between diff files in the chat and virtual diff viewer diff --git a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx index a9ccfdc103..33661a4f60 100644 --- a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, onCleanup, Show } from "solid-js" +import { createMemo, createSignal, onCleanup, Show, createEffect, on } from "solid-js" import type { Component } from "solid-js" import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code" import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff" @@ -32,6 +32,19 @@ const DiffVirtualContent: Component = () => { const [diff, setDiff] = createSignal(null) const [style, setStyle] = createSignal("unified") const [markdown, setMarkdown] = createSignal(false) + let scrollerRef: HTMLDivElement | undefined + + createEffect( + on( + diff, + () => { + if (scrollerRef) { + scrollerRef.scrollTop = 0 + } + }, + { defer: true }, + ), + ) const handler = (event: MessageEvent) => { const msg = event.data as { @@ -112,7 +125,7 @@ const DiffVirtualContent: Component = () => { -
+
(scrollerRef = el)}> {(v) => ( = (props) => { const vscode = useVSCode() + let scrollerRef: HTMLDivElement | undefined + + createEffect( + on( + () => props.filediff, + () => { + if (scrollerRef) { + scrollerRef.scrollTop = 0 + } + }, + { defer: true }, + ), + ) + const filename = createMemo(() => { const parts = props.filediff.file.split("/") return parts[parts.length - 1] ?? props.filediff.file @@ -71,7 +85,7 @@ export const PermissionDiff: Component = (props) => {
-
+
(scrollerRef = el)}> Diff preview unavailable for this file.
} From 725200270d120514fa1af10a677dcbec61ff68d7 Mon Sep 17 00:00:00 2001 From: jhapate0704 Date: Fri, 3 Jul 2026 23:21:17 +0530 Subject: [PATCH 02/33] refactor(vscode): remove dead scroll-reset code from PermissionDiff PermissionDock unmounts PermissionDiff instances on change, so scroll resets naturally. The fix belongs purely in DiffVirtualApp. Addresses review bot feedback --- .../src/components/chat/PermissionDiff.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx index d5e5eb0973..3136bdf086 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDiff.tsx @@ -1,4 +1,4 @@ -import { Show, type Component, createMemo, createEffect, on } from "solid-js" +import { Show, type Component, createMemo } from "solid-js" import { Diff } from "@kilocode/kilo-ui/diff" import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { IconButton } from "@kilocode/kilo-ui/icon-button" @@ -13,20 +13,6 @@ interface PermissionDiffProps { export const PermissionDiff: Component = (props) => { const vscode = useVSCode() - let scrollerRef: HTMLDivElement | undefined - - createEffect( - on( - () => props.filediff, - () => { - if (scrollerRef) { - scrollerRef.scrollTop = 0 - } - }, - { defer: true }, - ), - ) - const filename = createMemo(() => { const parts = props.filediff.file.split("/") return parts[parts.length - 1] ?? props.filediff.file @@ -85,7 +71,7 @@ export const PermissionDiff: Component = (props) => {
-
(scrollerRef = el)}> +
Diff preview unavailable for this file.
} From 4d676b68d2d0dd025c7d1a6684f49f3d03e9d12d Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 13 Jul 2026 20:35:31 -0400 Subject: [PATCH 03/33] feat(jetbrains): add file search backend toggle --- .changeset/jetbrains-file-search-backend.md | 5 + .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 68 +++++++++++- .../rpc/KiloWorkspaceRpcApiImplTest.kt | 104 ++++++++++++++++++ .../kilocode/backend/testing/MockCliServer.kt | 12 ++ .../actions/UseIntelliJFileSearchAction.kt | 23 ++++ .../app/KiloFileSearchSettingsService.kt | 48 ++++++++ .../client/app/KiloWorkspaceService.kt | 12 +- .../ui/editor/SessionEditorTextField.kt | 3 +- .../ui/prompt/KiloPromptCompletionProvider.kt | 27 +++-- .../resources/kilo.jetbrains.frontend.xml | 6 + .../resources/messages/KiloBundle.properties | 2 + .../UseIntelliJFileSearchActionTest.kt | 51 +++++++++ .../app/KiloFileSearchSettingsServiceTest.kt | 50 +++++++++ .../client/app/KiloWorkspaceServiceTest.kt | 23 ++++ .../client/session/ui/PromptPanelTest.kt | 36 ++---- .../KiloPromptCompletionProviderTest.kt | 15 +++ .../client/testing/FakeWorkspaceRpcApi.kt | 10 +- .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 10 +- .../kilocode/rpc/dto/FileSearchBackendDto.kt | 13 +++ 19 files changed, 473 insertions(+), 45 deletions(-) create mode 100644 .changeset/jetbrains-file-search-backend.md create mode 100644 packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt create mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt diff --git a/.changeset/jetbrains-file-search-backend.md b/.changeset/jetbrains-file-search-backend.md new file mode 100644 index 0000000000..da72367c76 --- /dev/null +++ b/.changeset/jetbrains-file-search-backend.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support switching JetBrains @ file completion between Kilo Core and the IntelliJ project index. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 7d0311b6c6..6cf105eb64 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -15,6 +15,7 @@ import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.isManagedWorktreeStorage import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -47,6 +48,7 @@ import com.intellij.navigation.NavigationItem import com.intellij.psi.PsiFileSystemItem import com.intellij.psi.search.GlobalSearchScope import com.intellij.util.indexing.FindSymbolParameters +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -56,6 +58,8 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json import okhttp3.Request import java.net.URI import java.net.URLDecoder @@ -74,7 +78,9 @@ import kotlin.coroutines.resume * for the given directory. Project lookup is only used to resolve the * calling frontend project to the correct backend directory. */ -class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { +class KiloWorkspaceRpcApiImpl internal constructor( + private val svc: KiloBackendAppService? = null, +) : KiloWorkspaceRpcApi { companion object { private val LOG = KiloLog.create(KiloWorkspaceRpcApiImpl::class.java) private const val SCHEMA = "https://app.kilo.ai/config.json" @@ -84,13 +90,14 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") private const val SEARCH_CAP = 2_000 private const val DIFF_CAP = 200_000 + private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ "${'$'}schema": "$SCHEMA" } """ } - private val app: KiloBackendAppService get() = service() + private val app: KiloBackendAppService get() = svc ?: service() private val gitCache = ConcurrentHashMap() @@ -193,9 +200,20 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { return found.values.toList() } - override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { + override suspend fun searchFiles( + directory: String, + query: String, + limit: Int, + backend: FileSearchBackendDto, + ): FileSearchResultDto { val base = file(clean(directory) ?: directory) ?: return FileSearchResultDto() val git = withContext(Dispatchers.IO) { gitAvailable(base) } + LOG.debug { "workspace file search backend=$backend directory=$directory query=$query limit=$limit" } + if (backend == FileSearchBackendDto.KILO) return searchKilo(directory, query, limit, git) + return searchIntellij(base, query, limit, git) + } + + private suspend fun searchIntellij(base: Path, query: String, limit: Int, git: Boolean): FileSearchResultDto { val project = project(base) ?: return FileSearchResultDto(git = git) if (DumbService.getInstance(project).isDumb) return FileSearchResultDto(indexing = true, git = git) return try { @@ -209,6 +227,49 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } } + private suspend fun searchKilo(directory: String, query: String, limit: Int, git: Boolean): FileSearchResultDto { + return try { + val cap = limit.coerceIn(1, 200) + val files = kiloResults(directory, query, "file", cap, false) + val dirs = kiloResults(directory, query, "directory", cap, true) + val found = linkedMapOf() + (dirs + files).forEach { file -> found.putIfAbsent(file.path, file) } + FileSearchResultDto(files = found.values.take(cap), git = git) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("Kilo Core file search failed for directory=$directory query=$query", e) + FileSearchResultDto(git = git) + } + } + + private suspend fun kiloResults( + directory: String, + query: String, + type: String, + limit: Int, + dir: Boolean, + ): List { + val http = app.http ?: throw IllegalStateException("Kilo HTTP client is unavailable") + val raw = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("http://127.0.0.1:${app.port}/find/file?directory=${encode(directory)}&query=${encode(query)}&type=$type&limit=$limit") + .get() + .build() + http.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) throw RuntimeException("HTTP ${response.code}: $body") + body + } + } + return JSON.decodeFromString>(raw) + .asSequence() + .map { it.trimEnd('/') } + .filter { it.isNotBlank() && !isManagedWorktreeStorage(it) } + .map { WorkspaceFileDto(it, it.substringAfterLast('/'), dir) } + .toList() + } + override suspend fun gitChanges(directory: String): String? = withContext(Dispatchers.IO) { val base = file(clean(directory) ?: directory) ?: return@withContext null if (!gitAvailable(base)) return@withContext null @@ -315,6 +376,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } private fun project(path: Path): Project? { + if (ApplicationManager.getApplication() == null) return null val projects = ProjectManager.getInstance().openProjects.filter { !it.isDefault } return projects.firstOrNull { item -> val base = item.basePath?.let(::file) ?: return@firstOrNull false diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt new file mode 100644 index 0000000000..45111f8c25 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt @@ -0,0 +1,104 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.backend.app.KiloAppState +import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.testing.FakeCliServer +import ai.kilocode.backend.testing.MockCliServer +import ai.kilocode.backend.testing.TestLog +import ai.kilocode.rpc.dto.FileSearchBackendDto +import ai.kilocode.rpc.dto.WorkspaceFileDto +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class KiloWorkspaceRpcApiImplTest { + private val mock = MockCliServer() + private val log = TestLog() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val apps = mutableListOf() + + @AfterTest + fun tearDown() = runBlocking { + apps.forEach { it.dispose() } + apps.clear() + scope.cancel() + mock.close() + } + + @Test + fun `Kilo backend searches files and directories through core`() = runBlocking { + mock.findFiles = """["src/Main.kt",".kilo/worktrees/hidden.kt"]""" + mock.findDirectories = """["src/","docs/"]""" + val dir = Files.createTempDirectory("kilo-search") + try { + val app = app() + + val result = KiloWorkspaceRpcApiImpl(app).searchFiles( + dir.toString(), + "src", + 3, + FileSearchBackendDto.KILO, + ) + + assertEquals( + listOf( + WorkspaceFileDto("src", "src", directory = true), + WorkspaceFileDto("docs", "docs", directory = true), + WorkspaceFileDto("src/Main.kt", "Main.kt"), + ), + result.files, + ) + assertEquals(2, mock.requestCount("/find/file")) + assertTrue(mock.findFilePaths.any { it.contains("type=file") && it.contains("query=src") }) + assertTrue(mock.findFilePaths.any { it.contains("type=directory") && it.contains("query=src") }) + } finally { + delete(dir) + } + } + + @Test + fun `IntelliJ backend does not call core file search`() = runBlocking { + val dir = Files.createTempDirectory("kilo-search") + try { + val app = app() + + KiloWorkspaceRpcApiImpl(app).searchFiles(dir.toString(), "src", 3, FileSearchBackendDto.INTELLIJ) + + assertEquals(0, mock.requestCount("/find/file")) + } finally { + delete(dir) + } + } + + private suspend fun app(): KiloBackendAppService { + val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } + app.connect() + val state = assertNotNull( + withTimeoutOrNull(35_000) { + app.appState.first { + it is KiloAppState.Ready || it is KiloAppState.Error || it is KiloAppState.MigrationRequired + } + }, + "App startup timed out in ${app.appState.value}; logs=${log.messages}", + ) + assertIs(state, "App startup failed; logs=${log.messages}") + return app + } + + private fun delete(dir: java.nio.file.Path) { + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 888b07e113..2ec50ae0ed 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -11,6 +11,7 @@ import java.net.ServerSocket import java.net.Socket import java.net.SocketException import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -87,6 +88,12 @@ class MockCliServer : AutoCloseable { @Volatile var commandsStatus = 200 @Volatile var skillsStatus = 200 + // File search responses + @Volatile var findFiles = "[]" + @Volatile var findDirectories = "[]" + @Volatile var findFileStatus = 200 + val findFilePaths = CopyOnWriteArrayList() + // Session REST responses @Volatile var sessions = "[]" @Volatile var recentSessions = "[]" @@ -370,6 +377,11 @@ class MockCliServer : AutoCloseable { } bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) + bare == "/find/file" -> { + findFilePaths.add(path) + val body = if (path.contains("type=directory")) findDirectories else findFiles + respond(output, findFileStatus, body) + } bare == "/mcp" -> respond(output, mcpStatus, mcp) bare.matches(Regex("/mcp/[^/]+/(connect|disconnect)")) && method == "POST" -> { lastMcpActionPath = path diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt new file mode 100644 index 0000000000..9329b56cc7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.app.KiloFileSearchSettingsService +import ai.kilocode.client.plugin.KiloBundle +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAwareToggleAction + +class UseIntelliJFileSearchAction : DumbAwareToggleAction( + KiloBundle.message("action.Kilo.FileSearch.UseIntelliJ.text"), + KiloBundle.message("action.Kilo.FileSearch.UseIntelliJ.description"), + null, +) { + override fun isSelected(e: AnActionEvent): Boolean { + return KiloFileSearchSettingsService.getInstance().useIntellij() + } + + override fun setSelected(e: AnActionEvent, state: Boolean) { + KiloFileSearchSettingsService.getInstance().setUseIntellij(state) + } + + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt new file mode 100644 index 0000000000..e2065e038b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt @@ -0,0 +1,48 @@ +package ai.kilocode.client.app + +import ai.kilocode.rpc.dto.FileSearchBackendDto +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service(Service.Level.APP) +@State( + name = "KiloFileSearchSettings", + storages = [Storage("kiloFileSearchSettings.xml")], +) +class KiloFileSearchSettingsService : PersistentStateComponent { + + data class State(var backend: String? = null) + + private var state = State() + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + fun backend(): FileSearchBackendDto = when (state.backend) { + "intellij" -> FileSearchBackendDto.INTELLIJ + else -> FileSearchBackendDto.KILO + } + + fun setBackend(value: FileSearchBackendDto) { + state.backend = when (value) { + FileSearchBackendDto.KILO -> "kilo" + FileSearchBackendDto.INTELLIJ -> "intellij" + } + } + + fun useIntellij(): Boolean = backend() == FileSearchBackendDto.INTELLIJ + + fun setUseIntellij(value: Boolean) { + setBackend(if (value) FileSearchBackendDto.INTELLIJ else FileSearchBackendDto.KILO) + } + + companion object { + fun getInstance(): KiloFileSearchSettingsService = service() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 5836ae0cea..fbc6cb7abb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -4,6 +4,7 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -139,8 +140,13 @@ class KiloWorkspaceService internal constructor( } suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto { + return searchFiles(directory, query, limit, fileSearchBackend()) + } + + suspend fun searchFiles(directory: String, query: String, limit: Int, backend: FileSearchBackendDto): FileSearchResultDto { + LOG.debug { "workspace file search backend=$backend directory=$directory query=$query limit=$limit" } return try { - call { searchFiles(directory, query, limit) } + call { searchFiles(directory, query, limit, backend) } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -149,6 +155,10 @@ class KiloWorkspaceService internal constructor( } } + fun fileSearchBackend(): FileSearchBackendDto { + return KiloFileSearchSettingsService.getInstance().backend() + } + suspend fun gitChanges(directory: String): String? { return try { call { gitChanges(directory) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index 7847bade60..6bc480645e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -10,7 +10,6 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.IdeActions -import com.intellij.openapi.actionSystem.PlatformCoreDataKeys import com.intellij.openapi.command.undo.UndoManager import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.ex.EditorEx @@ -74,11 +73,11 @@ internal open class SessionEditorTextField( super.uiDataSnapshot(sink) selection?.provideCopy(sink) { text } ctx?.let { sink.set(PromptDataKeys.SEND, it) } - file()?.let { sink.set(PlatformCoreDataKeys.FILE_EDITOR, it) } } private fun install(editor: Editor) { (editor as? EditorEx)?.setEmbeddedIntoDialogWrapper(true) + editor.putUserData(EditorTextField.SUPPLEMENTARY_KEY, true) // EditorImpl lazily creates EditorFloatingToolbar with the same first-show hook. // Settings providers run later, so this callback runs immediately after toolbar creation. UiNotifyConnector.doWhenFirstShown(editor.component) { hide(editor.component) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index 5e40551cd0..e888aca133 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.codeInsight.completion.CompletionParameters @@ -35,12 +36,14 @@ class KiloPromptCompletionProvider( private val paths = Collections.synchronizedSet(mutableSetOf()) private val exists = Collections.synchronizedMap(mutableMapOf()) private val pending = Collections.synchronizedSet(mutableSetOf()) - private val cache: MutableMap = Collections.synchronizedMap( - object : LinkedHashMap(64, 0.75f, true) { - override fun removeEldestEntry(eldest: Map.Entry) = size > 64 + private val cache: MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > 64 }, ) + private data class SearchKey(val prefix: String, val backend: FileSearchBackendDto) + data class Highlight(val start: Int, val end: Int, val kind: HighlightKind) enum class HighlightKind { MENTION, COMMAND, INVALID } @@ -73,10 +76,11 @@ class KiloPromptCompletionProvider( } fun prewarm() { - if (cache.containsKey("")) return scope.launch { - val result = service.searchFiles(workspace.directory, "", 50) - if (result.files.isNotEmpty() || result.git) cache.putIfAbsent("", result) + val key = SearchKey("", service.fileSearchBackend()) + if (cache.containsKey(key)) return@launch + val result = service.searchFiles(workspace.directory, "", 50, key.backend) + if (result.files.isNotEmpty() || result.git) cache.putIfAbsent(key, result) } } @@ -193,11 +197,14 @@ class KiloPromptCompletionProvider( } } - private fun search(prefix: String): FileSearchResultDto = cache[prefix] ?: fetch(prefix) + private fun search(prefix: String): FileSearchResultDto { + val key = SearchKey(prefix, service.fileSearchBackend()) + return cache[key] ?: fetch(key) + } - private fun fetch(prefix: String): FileSearchResultDto { - val result = runBlockingCancellable { service.searchFiles(workspace.directory, prefix, 50) } - cache[prefix] = result + private fun fetch(key: SearchKey): FileSearchResultDto { + val result = runBlockingCancellable { service.searchFiles(workspace.directory, key.prefix, 50, key.backend) } + cache[key] = result return result } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 1d913645b2..eb4ca1cbcb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -9,6 +9,7 @@ + + + @@ -132,6 +136,8 @@ + + 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 fa800d28d1..299ae35167 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -544,6 +544,8 @@ action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings action.Kilo.OpenSettings.text=Open Settings... action.Kilo.OpenSettings.description=Open Kilo Code settings dialog +action.Kilo.FileSearch.UseIntelliJ.text=Use IntelliJ for @ Completion +action.Kilo.FileSearch.UseIntelliJ.description=Use the IntelliJ project index instead of Kilo Core for @ file completion action.Kilo.OpenConfigGroup.text=Config Files action.Kilo.OpenConfigGroup.description=Open Kilo config files action.Kilo.OpenLocalConfig.text=Open: local {0} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt new file mode 100644 index 0000000000..63c4568caa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt @@ -0,0 +1,51 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.app.KiloFileSearchSettingsService +import ai.kilocode.rpc.dto.FileSearchBackendDto +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class UseIntelliJFileSearchActionTest : BasePlatformTestCase() { + private lateinit var settings: KiloFileSearchSettingsService + + override fun setUp() { + super.setUp() + settings = KiloFileSearchSettingsService.getInstance() + settings.loadState(KiloFileSearchSettingsService.State()) + } + + override fun tearDown() { + try { + settings.loadState(KiloFileSearchSettingsService.State()) + } finally { + super.tearDown() + } + } + + fun `test selected reflects settings`() { + val action = UseIntelliJFileSearchAction() + + assertFalse(action.isSelected(event(action))) + + settings.setBackend(FileSearchBackendDto.INTELLIJ) + assertTrue(action.isSelected(event(action))) + } + + fun `test set selected writes backend`() { + val action = UseIntelliJFileSearchAction() + val event = event(action) + + action.setSelected(event, true) + assertEquals(FileSearchBackendDto.INTELLIJ, settings.backend()) + + action.setSelected(event, false) + assertEquals(FileSearchBackendDto.KILO, settings.backend()) + } + + private fun event(action: UseIntelliJFileSearchAction): AnActionEvent { + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + return AnActionEvent.createFromDataContext("", presentation) { null } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt new file mode 100644 index 0000000000..67ad0ac5ed --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt @@ -0,0 +1,50 @@ +package ai.kilocode.client.app + +import ai.kilocode.rpc.dto.FileSearchBackendDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class KiloFileSearchSettingsServiceTest : BasePlatformTestCase() { + private lateinit var settings: KiloFileSearchSettingsService + + override fun setUp() { + super.setUp() + settings = KiloFileSearchSettingsService.getInstance() + settings.loadState(KiloFileSearchSettingsService.State()) + } + + override fun tearDown() { + try { + settings.loadState(KiloFileSearchSettingsService.State()) + } finally { + super.tearDown() + } + } + + fun `test default backend is Kilo`() { + assertEquals(FileSearchBackendDto.KILO, settings.backend()) + assertFalse(settings.useIntellij()) + } + + fun `test setting IntelliJ persists state`() { + settings.setBackend(FileSearchBackendDto.INTELLIJ) + + assertEquals("intellij", settings.state.backend) + assertEquals(FileSearchBackendDto.INTELLIJ, settings.backend()) + assertTrue(settings.useIntellij()) + } + + fun `test invalid backend falls back to Kilo`() { + settings.loadState(KiloFileSearchSettingsService.State("unknown")) + + assertEquals(FileSearchBackendDto.KILO, settings.backend()) + assertFalse(settings.useIntellij()) + } + + fun `test setUseIntellij toggles backend`() { + settings.setUseIntellij(true) + assertEquals(FileSearchBackendDto.INTELLIJ, settings.backend()) + + settings.setUseIntellij(false) + assertEquals(FileSearchBackendDto.KILO, settings.backend()) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 607fe2493d..96ddb042ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope @@ -19,6 +20,7 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope = CoroutineScope(SupervisorJob()) rpc = FakeWorkspaceRpcApi() service = KiloWorkspaceService(scope, rpc) @@ -26,6 +28,7 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope.cancel() } finally { super.tearDown() @@ -97,4 +100,24 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(err.message, seen?.message) assertEquals(listOf("dep"), rpc.searchQueries) } + + fun `test searchFiles sends default Kilo backend`() = runBlocking { + withContext(Dispatchers.Default) { + service.searchFiles("/test", "src") + } + + assertEquals(listOf("src"), rpc.searchQueries) + assertEquals(listOf(FileSearchBackendDto.KILO), rpc.searchBackends) + } + + fun `test searchFiles sends IntelliJ backend after setting change`() = runBlocking { + KiloFileSearchSettingsService.getInstance().setBackend(FileSearchBackendDto.INTELLIJ) + + withContext(Dispatchers.Default) { + service.searchFiles("/test", "src") + } + + assertEquals(listOf("src"), rpc.searchQueries) + assertEquals(listOf(FileSearchBackendDto.INTELLIJ), rpc.searchBackends) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index b8020f2c64..c315003159 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -44,7 +44,6 @@ import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.command.WriteCommandAction -import com.intellij.openapi.command.undo.UndoManager import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory @@ -56,7 +55,6 @@ import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.markup.TextAttributes -import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.PlainTextLanguage @@ -380,28 +378,20 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(spans.contains("@unknown" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES)) } - fun `test prompt editor exposes file editor for undo redo`() { + fun `test prompt editor does not expose file editor for platform undo`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) val field = panel.defaultFocusedComponent as EditorTextField realize(panel, 260, 400) val editor = field.getEditor(false)!! - WriteCommandAction.runWriteCommandAction(project) { - editor.document.insertString(0, "hello") - } val sink = TestSink() (field as UiDataProvider).uiDataSnapshot(sink) - val file = sink.file as? TextEditor ?: error("missing file editor") - assertNotNull(file) - assertSame(editor.document, file.editor.document) - UndoManager.getInstance(project).undo(file) - assertEquals("", editor.document.text) - UndoManager.getInstance(project).redo(file) - assertEquals("hello", editor.document.text) + assertNull(sink.file) + assertSame(true, editor.getUserData(EditorTextField.SUPPLEMENTARY_KEY)) } - fun `test prompt editor platform undo redo actions target prompt editor`() { + fun `test prompt editor platform undo redo actions do not throw`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) val field = panel.defaultFocusedComponent as EditorTextField @@ -413,13 +403,10 @@ class PromptPanelTest : BasePlatformTestCase() { assertSame(true, editor.contentComponent.getClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER)) val sink = TestSink() (field as UiDataProvider).uiDataSnapshot(sink) - val file = sink.file as? TextEditor ?: error("missing file editor") - assertSame(editor.document, file.editor.document) - assertTrue("prompt file editor should have undo", UndoManager.getInstance(project).isUndoAvailable(file)) + assertNull(sink.file) - invokeAction(IdeActions.ACTION_UNDO, editor.contentComponent, file) - assertEquals("", editor.document.text) - invokeAction(IdeActions.ACTION_REDO, editor.contentComponent, file) + updatePlatformAction(IdeActions.ACTION_UNDO, editor) + updatePlatformAction(IdeActions.ACTION_REDO, editor) assertEquals("hello", editor.document.text) } @@ -1280,21 +1267,18 @@ class PromptPanelTest : BasePlatformTestCase() { UIUtil.dispatchAllInvocationEvents() } - private fun invokeAction(id: String, component: java.awt.Component, file: TextEditor) { + private fun updatePlatformAction(id: String, editor: Editor) { val action = ActionManager.getInstance().getAction(id) ?: error("missing action $id") val ctx = DataContext { data -> when (data) { CommonDataKeys.PROJECT.name -> project - PlatformCoreDataKeys.CONTEXT_COMPONENT.name -> component - PlatformCoreDataKeys.FILE_EDITOR.name -> file + CommonDataKeys.EDITOR.name -> editor + PlatformCoreDataKeys.CONTEXT_COMPONENT.name -> editor.contentComponent else -> null } } val event = AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null) ActionUtil.updateAction(action, event) - assertTrue("action $id should be enabled", event.presentation.isEnabled) - ActionUtil.performAction(action, event) - UIUtil.dispatchAllInvocationEvents() } private fun waitForLookupItems(editor: Editor): List { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index 76077daeab..e87dbc1964 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -1,9 +1,11 @@ package ai.kilocode.client.session.ui.prompt import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.KiloFileSearchSettingsService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -26,6 +28,7 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) rpc = FakeWorkspaceRpcApi() val workspaces = KiloWorkspaceService(scope, rpc) @@ -48,6 +51,7 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { override fun tearDown() { try { + KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope.cancel() } finally { super.tearDown() @@ -89,6 +93,17 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertEquals(listOf("main"), rpc.searchQueries) } + fun `test mention completion refetches same prefix after backend changes`() { + rpc.search = { query -> FileSearchResultDto(files = listOf(file("kilo/$query.kt"))) } + + complete("@main") + KiloFileSearchSettingsService.getInstance().setBackend(FileSearchBackendDto.INTELLIJ) + complete("@main") + + assertEquals(listOf("main", "main"), rpc.searchQueries) + assertEquals(listOf(FileSearchBackendDto.KILO, FileSearchBackendDto.INTELLIJ), rpc.searchBackends) + } + fun `test clearing mentions resets cached prefix result`() { rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 4c1b60ef21..6b143985b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -45,6 +46,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var beforeGlobalConfigTarget: (suspend () -> Unit)? = null val fileCalls = CopyOnWriteArrayList>() val searchQueries = CopyOnWriteArrayList() + val searchBackends = CopyOnWriteArrayList() val opened = CopyOnWriteArrayList() val openedFiles = CopyOnWriteArrayList() val localConfigs = CopyOnWriteArrayList() @@ -81,9 +83,15 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return fileResolver?.invoke(path) ?: fileMatches } - override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { + override suspend fun searchFiles( + directory: String, + query: String, + limit: Int, + backend: FileSearchBackendDto, + ): FileSearchResultDto { assertNotEdt("searchFiles") searchQueries.add(query) + searchBackends.add(backend) return search?.invoke(query) ?: searchResult } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 0356b6c0e6..7ea0cdd6f9 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ConfigTargetDto +import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto @@ -48,8 +49,13 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Resolve [path] to matching files, scoped primarily to [directory]. */ suspend fun files(directory: String, path: String): List - /** Fuzzy file/folder search via the backend IDE index. */ - suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto + /** Fuzzy file/folder search via the selected backend. */ + suspend fun searchFiles( + directory: String, + query: String, + limit: Int = 50, + backend: FileSearchBackendDto = FileSearchBackendDto.KILO, + ): FileSearchResultDto /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ suspend fun gitChanges(directory: String): String? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt new file mode 100644 index 0000000000..a6565e7b75 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt @@ -0,0 +1,13 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class FileSearchBackendDto { + @SerialName("kilo") + KILO, + + @SerialName("intellij") + INTELLIJ, +} From a745a7b1980cbad32d3e32b971663c8a56721292 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 14 Jul 2026 11:20:01 -0400 Subject: [PATCH 04/33] fix(core): restore pre-push typecheck --- bun.lock | 1 + packages/core/package.json | 1 + packages/core/src/database/sqlite.node.ts | 13 +++++++++---- packages/effect-sqlite-node/src/index.ts | 19 +++++++++++++------ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index e0f443bced..affce8acac 100644 --- a/bun.lock +++ b/bun.lock @@ -103,6 +103,7 @@ }, "devDependencies": { "@opencode-ai/http-recorder": "workspace:*", + "@opencode-ai/llm": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", diff --git a/packages/core/package.json b/packages/core/package.json index f42f427583..07e88843d0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,7 @@ "@types/node": "catalog:", "@types/turndown": "5.0.5", "@types/which": "3.0.4", + "@opencode-ai/llm": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 6eaecbee26..5547c87652 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -1,4 +1,4 @@ -import { DatabaseSync, type SQLInputValue } from "node:sqlite" +import { DatabaseSync } from "node:sqlite" import { drizzle } from "drizzle-orm/node-sqlite" import * as Context from "effect/Context" import * as Effect from "effect/Effect" @@ -17,6 +17,11 @@ import { Sqlite } from "./sqlite" const ATTR_DB_SYSTEM_NAME = "db.system.name" +type SqliteValue = null | number | bigint | string | Uint8Array +type SqliteStatement = ReturnType & { + readonly setReturnArrays: (value: boolean) => void +} + const TypeId = "~@opencode-ai/core/database/SqliteNode" as const type TypeId = typeof TypeId @@ -58,7 +63,7 @@ const make = (options: Config) => const statement = native.prepare(query) statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) try { - return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) + return Effect.succeed(statement.all(...(params as SqliteValue[])) as Array>) } catch (cause) { return Effect.fail( new SqlError({ @@ -70,12 +75,12 @@ const make = (options: Config) => const runValues = (query: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { - const statement = native.prepare(query) + const statement = native.prepare(query) as SqliteStatement statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReturnArrays(true) try { return Effect.succeed( - statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, + statement.all(...(params as SqliteValue[])) as unknown as ReadonlyArray>, ) } catch (cause) { return Effect.fail( diff --git a/packages/effect-sqlite-node/src/index.ts b/packages/effect-sqlite-node/src/index.ts index 37e255391d..2ab2cd80cc 100644 --- a/packages/effect-sqlite-node/src/index.ts +++ b/packages/effect-sqlite-node/src/index.ts @@ -1,6 +1,6 @@ export * as NodeSqliteClient from "./index" -import { DatabaseSync, type SQLInputValue } from "node:sqlite" +import { DatabaseSync } from "node:sqlite" import { identity } from "effect/Function" import * as Context from "effect/Context" import * as Effect from "effect/Effect" @@ -17,6 +17,12 @@ import * as Statement from "effect/unstable/sql/Statement" const ATTR_DB_SYSTEM_NAME = "db.system.name" +type SqliteValue = null | number | bigint | string | Uint8Array +type SqliteOptions = NonNullable[1]> & { readonly timeout?: number } +type SqliteStatement = ReturnType & { + readonly setReturnArrays: (value: boolean) => void +} + export const TypeId: TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient" export type TypeId = "~@opencode-ai/effect-sqlite-node/NodeSqliteClient" @@ -56,13 +62,14 @@ export const make = ( : undefined const makeConnection = Effect.gen(function* () { - const db = new DatabaseSync(options.filename, { + const opts: SqliteOptions = { readOnly: options.readonly, timeout: options.timeout, allowExtension: options.allowExtension, enableForeignKeyConstraints: true, open: true, - }) + } + const db = new DatabaseSync(options.filename, opts) yield* Effect.addFinalizer(() => Effect.sync(() => db.close())) if (options.disableWAL !== true && options.readonly !== true) { @@ -74,7 +81,7 @@ export const make = ( const statement = db.prepare(sql) statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) try { - return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array>) + return Effect.succeed(statement.all(...(params as SqliteValue[])) as Array>) } catch (cause) { return Effect.fail( new SqlError({ @@ -86,12 +93,12 @@ export const make = ( const runValues = (sql: string, params: ReadonlyArray = []) => Effect.withFiber>, SqlError>((fiber) => { - const statement = db.prepare(sql) + const statement = db.prepare(sql) as SqliteStatement statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers)) statement.setReturnArrays(true) try { return Effect.succeed( - statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray>, + statement.all(...(params as SqliteValue[])) as unknown as ReadonlyArray>, ) } catch (cause) { return Effect.fail( From d5222987fb8beb310cdd2c483de81a46bcb1ff54 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 16 Jul 2026 10:41:54 -0400 Subject: [PATCH 05/33] fix(jetbrains): always use core file search --- .changeset/jetbrains-file-search-backend.md | 2 +- .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 119 +----------------- .../rpc/KiloWorkspaceRpcApiImplTest.kt | 24 +--- .../actions/UseIntelliJFileSearchAction.kt | 23 ---- .../app/KiloFileSearchSettingsService.kt | 48 ------- .../client/app/KiloWorkspaceService.kt | 13 +- .../ui/prompt/KiloPromptCompletionProvider.kt | 27 ++-- .../resources/kilo.jetbrains.frontend.xml | 6 - .../resources/messages/KiloBundle.properties | 2 - .../UseIntelliJFileSearchActionTest.kt | 51 -------- .../app/KiloFileSearchSettingsServiceTest.kt | 50 -------- .../client/app/KiloWorkspaceServiceTest.kt | 17 +-- .../KiloPromptCompletionProviderTest.kt | 15 --- .../client/testing/FakeWorkspaceRpcApi.kt | 10 +- .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 10 +- .../kilocode/rpc/dto/FileSearchBackendDto.kt | 13 -- 16 files changed, 22 insertions(+), 408 deletions(-) delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt delete mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt delete mode 100644 packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt diff --git a/.changeset/jetbrains-file-search-backend.md b/.changeset/jetbrains-file-search-backend.md index da72367c76..8699b39637 100644 --- a/.changeset/jetbrains-file-search-backend.md +++ b/.changeset/jetbrains-file-search-backend.md @@ -2,4 +2,4 @@ "@kilocode/kilo-jetbrains": patch --- -Support switching JetBrains @ file completion between Kilo Core and the IntelliJ project index. +Use Kilo Core for JetBrains @ file completion. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 6cf105eb64..13eadb01ee 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -15,7 +15,6 @@ import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.isManagedWorktreeStorage import ai.kilocode.rpc.dto.ConfigTargetDto -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -23,31 +22,17 @@ import ai.kilocode.rpc.dto.ModelsWorkspaceDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.process.CapturingProcessHandler -import com.intellij.ide.actions.searcheverywhere.FoundItemDescriptor -import com.intellij.ide.util.gotoByName.ChooseByNameInScopeItemProvider -import com.intellij.ide.util.gotoByName.ChooseByNamePopup -import com.intellij.ide.util.gotoByName.ChooseByNameViewModel -import com.intellij.ide.util.gotoByName.GotoFileModel import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.readAction import com.intellij.openapi.components.service -import com.intellij.openapi.progress.EmptyProgressIndicator -import com.intellij.openapi.project.DumbService -import com.intellij.openapi.project.IndexNotReadyException import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager -import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.platform.project.ProjectId import com.intellij.platform.project.findProjectOrNull -import com.intellij.navigation.NavigationItem -import com.intellij.psi.PsiFileSystemItem -import com.intellij.psi.search.GlobalSearchScope -import com.intellij.util.indexing.FindSymbolParameters import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -88,7 +73,6 @@ class KiloWorkspaceRpcApiImpl internal constructor( private val LEGACY = listOf("opencode.jsonc", "opencode.json") private val GLOBAL = MODERN + LEGACY + "config.json" private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode") - private const val SEARCH_CAP = 2_000 private const val DIFF_CAP = 200_000 private val JSON = Json { ignoreUnknownKeys = true } private val CONFIG = """{ @@ -200,31 +184,11 @@ class KiloWorkspaceRpcApiImpl internal constructor( return found.values.toList() } - override suspend fun searchFiles( - directory: String, - query: String, - limit: Int, - backend: FileSearchBackendDto, - ): FileSearchResultDto { + override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { val base = file(clean(directory) ?: directory) ?: return FileSearchResultDto() val git = withContext(Dispatchers.IO) { gitAvailable(base) } - LOG.debug { "workspace file search backend=$backend directory=$directory query=$query limit=$limit" } - if (backend == FileSearchBackendDto.KILO) return searchKilo(directory, query, limit, git) - return searchIntellij(base, query, limit, git) - } - - private suspend fun searchIntellij(base: Path, query: String, limit: Int, git: Boolean): FileSearchResultDto { - val project = project(base) ?: return FileSearchResultDto(git = git) - if (DumbService.getInstance(project).isDumb) return FileSearchResultDto(indexing = true, git = git) - return try { - val files = readAction { search(project, base, query, limit.coerceIn(1, 200)) } - FileSearchResultDto(files = files, git = git) - } catch (e: IndexNotReadyException) { - FileSearchResultDto(indexing = true, git = git) - } catch (e: LinkageError) { - LOG.warn("file search API unavailable; returning no suggestions", e) - FileSearchResultDto(git = git) - } + LOG.debug { "workspace file search directory=$directory query=$query limit=$limit" } + return searchKilo(directory, query, limit, git) } private suspend fun searchKilo(directory: String, query: String, limit: Int, git: Boolean): FileSearchResultDto { @@ -384,83 +348,6 @@ class KiloWorkspaceRpcApiImpl internal constructor( } ?: projects.firstOrNull() } - // Uses the IDE Go-to-File engine (com.intellij.ide.util.gotoByName.*). These are public but - // unstable lang-impl classes (not @ApiStatus.Internal) -- the same engine behind Search Everywhere, - // chosen for proven large-repo performance. searchFiles() degrades gracefully on LinkageError. - @Suppress("UnstableApiUsage") - private fun search(project: Project, base: Path, query: String, limit: Int): List { - val text = query.trim() - if (text.isBlank()) return roots(project, base, limit) - val scope = GlobalSearchScope.projectScope(project) - val model = object : GotoFileModel(project) { - override fun acceptItem(item: NavigationItem): Boolean { - val psi = item as? PsiFileSystemItem ?: return false - val path = file(psi.virtualFile.path) ?: return false - return relativeWithinWorkspace(base, path) != null && super.acceptItem(item) - } - - override fun loadInitialCheckBoxState(): Boolean = false - - override fun saveInitialCheckBoxState(state: Boolean) {} - } - val view = object : ChooseByNameViewModel { - override fun getProject(): Project = project - - override fun getModel() = model - - override fun isSearchInAnyPlace(): Boolean = model.useMiddleMatching() - - override fun transformPattern(pattern: String): String = ChooseByNamePopup.getTransformedPattern(pattern, model) - - override fun canShowListForEmptyPattern(): Boolean = false - - override fun getMaximumListSizeLimit(): Int = limit - } - val provider = model.getItemProvider(null) - val params = FindSymbolParameters.wrap(text, scope) - val found = mutableListOf>() - val indicator = EmptyProgressIndicator() - if (provider is ChooseByNameInScopeItemProvider) { - provider.filterElementsWithWeights(view, params, indicator) { item -> - found += item - found.size < SEARCH_CAP - } - } else { - provider.filterElements(view, text, false, indicator) { item -> - found += FoundItemDescriptor(item, 0) - found.size < SEARCH_CAP - } - } - return found.asSequence() - .sortedByDescending { it.weight } - .mapNotNull { item -> (item.item as? PsiFileSystemItem)?.virtualFile } - .mapNotNull { vf -> fileDto(base, vf) } - .distinctBy { it.path } - .take(limit) - .toList() - } - - private fun roots(project: Project, base: Path, limit: Int): List { - val root = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(base) ?: return emptyList() - val index = ProjectFileIndex.getInstance(project) - return root.children.asSequence() - .filter { it.name != ".git" } - .filterNot { index.isExcluded(it) } - .mapNotNull { fileDto(base, it) } - .sortedWith( - compareByDescending { it.directory } - .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, - ) - .take(limit) - .toList() - } - - private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? { - val path = file(vf.path) ?: return null - val rel = relativeWithinWorkspace(base, path) ?: return null - return WorkspaceFileDto(rel, vf.name, vf.isDirectory) - } - private fun gitAvailable(base: Path): Boolean { return workspaceGitAvailable(base, gitCache) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt index 45111f8c25..c6e4f94fcf 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImplTest.kt @@ -5,7 +5,6 @@ import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.WorkspaceFileDto import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,19 +36,14 @@ class KiloWorkspaceRpcApiImplTest { } @Test - fun `Kilo backend searches files and directories through core`() = runBlocking { + fun `searches files and directories through core`() = runBlocking { mock.findFiles = """["src/Main.kt",".kilo/worktrees/hidden.kt"]""" mock.findDirectories = """["src/","docs/"]""" val dir = Files.createTempDirectory("kilo-search") try { val app = app() - val result = KiloWorkspaceRpcApiImpl(app).searchFiles( - dir.toString(), - "src", - 3, - FileSearchBackendDto.KILO, - ) + val result = KiloWorkspaceRpcApiImpl(app).searchFiles(dir.toString(), "src", 3) assertEquals( listOf( @@ -67,20 +61,6 @@ class KiloWorkspaceRpcApiImplTest { } } - @Test - fun `IntelliJ backend does not call core file search`() = runBlocking { - val dir = Files.createTempDirectory("kilo-search") - try { - val app = app() - - KiloWorkspaceRpcApiImpl(app).searchFiles(dir.toString(), "src", 3, FileSearchBackendDto.INTELLIJ) - - assertEquals(0, mock.requestCount("/find/file")) - } finally { - delete(dir) - } - } - private suspend fun app(): KiloBackendAppService { val app = KiloBackendAppService.create(scope, FakeCliServer(mock), log).also { apps.add(it) } app.connect() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt deleted file mode 100644 index 9329b56cc7..0000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchAction.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ai.kilocode.client.actions - -import ai.kilocode.client.app.KiloFileSearchSettingsService -import ai.kilocode.client.plugin.KiloBundle -import com.intellij.openapi.actionSystem.ActionUpdateThread -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.project.DumbAwareToggleAction - -class UseIntelliJFileSearchAction : DumbAwareToggleAction( - KiloBundle.message("action.Kilo.FileSearch.UseIntelliJ.text"), - KiloBundle.message("action.Kilo.FileSearch.UseIntelliJ.description"), - null, -) { - override fun isSelected(e: AnActionEvent): Boolean { - return KiloFileSearchSettingsService.getInstance().useIntellij() - } - - override fun setSelected(e: AnActionEvent, state: Boolean) { - KiloFileSearchSettingsService.getInstance().setUseIntellij(state) - } - - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt deleted file mode 100644 index e2065e038b..0000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsService.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ai.kilocode.client.app - -import ai.kilocode.rpc.dto.FileSearchBackendDto -import com.intellij.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import com.intellij.openapi.components.service - -@Service(Service.Level.APP) -@State( - name = "KiloFileSearchSettings", - storages = [Storage("kiloFileSearchSettings.xml")], -) -class KiloFileSearchSettingsService : PersistentStateComponent { - - data class State(var backend: String? = null) - - private var state = State() - - override fun getState(): State = state - - override fun loadState(state: State) { - this.state = state - } - - fun backend(): FileSearchBackendDto = when (state.backend) { - "intellij" -> FileSearchBackendDto.INTELLIJ - else -> FileSearchBackendDto.KILO - } - - fun setBackend(value: FileSearchBackendDto) { - state.backend = when (value) { - FileSearchBackendDto.KILO -> "kilo" - FileSearchBackendDto.INTELLIJ -> "intellij" - } - } - - fun useIntellij(): Boolean = backend() == FileSearchBackendDto.INTELLIJ - - fun setUseIntellij(value: Boolean) { - setBackend(if (value) FileSearchBackendDto.INTELLIJ else FileSearchBackendDto.KILO) - } - - companion object { - fun getInstance(): KiloFileSearchSettingsService = service() - } -} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index fbc6cb7abb..0bba46a996 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -4,7 +4,6 @@ package ai.kilocode.client.app import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -140,13 +139,9 @@ class KiloWorkspaceService internal constructor( } suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto { - return searchFiles(directory, query, limit, fileSearchBackend()) - } - - suspend fun searchFiles(directory: String, query: String, limit: Int, backend: FileSearchBackendDto): FileSearchResultDto { - LOG.debug { "workspace file search backend=$backend directory=$directory query=$query limit=$limit" } + LOG.debug { "workspace file search directory=$directory query=$query limit=$limit" } return try { - call { searchFiles(directory, query, limit, backend) } + call { searchFiles(directory, query, limit) } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -155,10 +150,6 @@ class KiloWorkspaceService internal constructor( } } - fun fileSearchBackend(): FileSearchBackendDto { - return KiloFileSearchSettingsService.getInstance().backend() - } - suspend fun gitChanges(directory: String): String? { return try { call { gitChanges(directory) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index e888aca133..a2b6331bbb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -4,7 +4,6 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.rpc.dto.CommandDto -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.codeInsight.completion.CompletionParameters @@ -36,14 +35,12 @@ class KiloPromptCompletionProvider( private val paths = Collections.synchronizedSet(mutableSetOf()) private val exists = Collections.synchronizedMap(mutableMapOf()) private val pending = Collections.synchronizedSet(mutableSetOf()) - private val cache: MutableMap = Collections.synchronizedMap( - object : LinkedHashMap(64, 0.75f, true) { - override fun removeEldestEntry(eldest: Map.Entry) = size > 64 + private val cache: MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry) = size > 64 }, ) - private data class SearchKey(val prefix: String, val backend: FileSearchBackendDto) - data class Highlight(val start: Int, val end: Int, val kind: HighlightKind) enum class HighlightKind { MENTION, COMMAND, INVALID } @@ -77,10 +74,9 @@ class KiloPromptCompletionProvider( fun prewarm() { scope.launch { - val key = SearchKey("", service.fileSearchBackend()) - if (cache.containsKey(key)) return@launch - val result = service.searchFiles(workspace.directory, "", 50, key.backend) - if (result.files.isNotEmpty() || result.git) cache.putIfAbsent(key, result) + if (cache.containsKey("")) return@launch + val result = service.searchFiles(workspace.directory, "", 50) + if (result.files.isNotEmpty() || result.git) cache.putIfAbsent("", result) } } @@ -197,14 +193,11 @@ class KiloPromptCompletionProvider( } } - private fun search(prefix: String): FileSearchResultDto { - val key = SearchKey(prefix, service.fileSearchBackend()) - return cache[key] ?: fetch(key) - } + private fun search(prefix: String): FileSearchResultDto = cache[prefix] ?: fetch(prefix) - private fun fetch(key: SearchKey): FileSearchResultDto { - val result = runBlockingCancellable { service.searchFiles(workspace.directory, key.prefix, 50, key.backend) } - cache[key] = result + private fun fetch(prefix: String): FileSearchResultDto { + val result = runBlockingCancellable { service.searchFiles(workspace.directory, prefix, 50) } + cache[prefix] = result return result } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 24a301b352..d08df1f9f9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -9,7 +9,6 @@ - - - @@ -141,8 +137,6 @@ - - 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 48ef51c652..0e9ff7e50d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -549,8 +549,6 @@ action.Kilo.SettingsGroup.text=Settings action.Kilo.SettingsGroup.description=Kilo Code settings action.Kilo.OpenSettings.text=Open Settings... action.Kilo.OpenSettings.description=Open Kilo Code settings dialog -action.Kilo.FileSearch.UseIntelliJ.text=Use IntelliJ for @ Completion -action.Kilo.FileSearch.UseIntelliJ.description=Use the IntelliJ project index instead of Kilo Core for @ file completion action.Kilo.OpenConfigGroup.text=Config Files action.Kilo.OpenConfigGroup.description=Open Kilo config files action.Kilo.OpenLocalConfig.text=Open: local {0} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt deleted file mode 100644 index 63c4568caa..0000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/UseIntelliJFileSearchActionTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package ai.kilocode.client.actions - -import ai.kilocode.client.app.KiloFileSearchSettingsService -import ai.kilocode.rpc.dto.FileSearchBackendDto -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.Presentation -import com.intellij.testFramework.fixtures.BasePlatformTestCase - -@Suppress("UnstableApiUsage") -class UseIntelliJFileSearchActionTest : BasePlatformTestCase() { - private lateinit var settings: KiloFileSearchSettingsService - - override fun setUp() { - super.setUp() - settings = KiloFileSearchSettingsService.getInstance() - settings.loadState(KiloFileSearchSettingsService.State()) - } - - override fun tearDown() { - try { - settings.loadState(KiloFileSearchSettingsService.State()) - } finally { - super.tearDown() - } - } - - fun `test selected reflects settings`() { - val action = UseIntelliJFileSearchAction() - - assertFalse(action.isSelected(event(action))) - - settings.setBackend(FileSearchBackendDto.INTELLIJ) - assertTrue(action.isSelected(event(action))) - } - - fun `test set selected writes backend`() { - val action = UseIntelliJFileSearchAction() - val event = event(action) - - action.setSelected(event, true) - assertEquals(FileSearchBackendDto.INTELLIJ, settings.backend()) - - action.setSelected(event, false) - assertEquals(FileSearchBackendDto.KILO, settings.backend()) - } - - private fun event(action: UseIntelliJFileSearchAction): AnActionEvent { - val presentation = Presentation().apply { copyFrom(action.templatePresentation) } - return AnActionEvent.createFromDataContext("", presentation) { null } - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt deleted file mode 100644 index 67ad0ac5ed..0000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloFileSearchSettingsServiceTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ai.kilocode.client.app - -import ai.kilocode.rpc.dto.FileSearchBackendDto -import com.intellij.testFramework.fixtures.BasePlatformTestCase - -class KiloFileSearchSettingsServiceTest : BasePlatformTestCase() { - private lateinit var settings: KiloFileSearchSettingsService - - override fun setUp() { - super.setUp() - settings = KiloFileSearchSettingsService.getInstance() - settings.loadState(KiloFileSearchSettingsService.State()) - } - - override fun tearDown() { - try { - settings.loadState(KiloFileSearchSettingsService.State()) - } finally { - super.tearDown() - } - } - - fun `test default backend is Kilo`() { - assertEquals(FileSearchBackendDto.KILO, settings.backend()) - assertFalse(settings.useIntellij()) - } - - fun `test setting IntelliJ persists state`() { - settings.setBackend(FileSearchBackendDto.INTELLIJ) - - assertEquals("intellij", settings.state.backend) - assertEquals(FileSearchBackendDto.INTELLIJ, settings.backend()) - assertTrue(settings.useIntellij()) - } - - fun `test invalid backend falls back to Kilo`() { - settings.loadState(KiloFileSearchSettingsService.State("unknown")) - - assertEquals(FileSearchBackendDto.KILO, settings.backend()) - assertFalse(settings.useIntellij()) - } - - fun `test setUseIntellij toggles backend`() { - settings.setUseIntellij(true) - assertEquals(FileSearchBackendDto.INTELLIJ, settings.backend()) - - settings.setUseIntellij(false) - assertEquals(FileSearchBackendDto.KILO, settings.backend()) - } -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt index 96ddb042ba..2691c32705 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloWorkspaceServiceTest.kt @@ -1,7 +1,6 @@ package ai.kilocode.client.app import ai.kilocode.client.testing.FakeWorkspaceRpcApi -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.WorkspaceFileDto import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope @@ -20,7 +19,6 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { override fun setUp() { super.setUp() - KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope = CoroutineScope(SupervisorJob()) rpc = FakeWorkspaceRpcApi() service = KiloWorkspaceService(scope, rpc) @@ -28,7 +26,6 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { override fun tearDown() { try { - KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope.cancel() } finally { super.tearDown() @@ -101,23 +98,11 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() { assertEquals(listOf("dep"), rpc.searchQueries) } - fun `test searchFiles sends default Kilo backend`() = runBlocking { + fun `test searchFiles sends query to RPC`() = runBlocking { withContext(Dispatchers.Default) { service.searchFiles("/test", "src") } assertEquals(listOf("src"), rpc.searchQueries) - assertEquals(listOf(FileSearchBackendDto.KILO), rpc.searchBackends) - } - - fun `test searchFiles sends IntelliJ backend after setting change`() = runBlocking { - KiloFileSearchSettingsService.getInstance().setBackend(FileSearchBackendDto.INTELLIJ) - - withContext(Dispatchers.Default) { - service.searchFiles("/test", "src") - } - - assertEquals(listOf("src"), rpc.searchQueries) - assertEquals(listOf(FileSearchBackendDto.INTELLIJ), rpc.searchBackends) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index e87dbc1964..76077daeab 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -1,11 +1,9 @@ package ai.kilocode.client.session.ui.prompt import ai.kilocode.client.app.KiloWorkspaceService -import ai.kilocode.client.app.KiloFileSearchSettingsService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.rpc.dto.CommandDto -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -28,7 +26,6 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { override fun setUp() { super.setUp() - KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) rpc = FakeWorkspaceRpcApi() val workspaces = KiloWorkspaceService(scope, rpc) @@ -51,7 +48,6 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { override fun tearDown() { try { - KiloFileSearchSettingsService.getInstance().loadState(KiloFileSearchSettingsService.State()) scope.cancel() } finally { super.tearDown() @@ -93,17 +89,6 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { assertEquals(listOf("main"), rpc.searchQueries) } - fun `test mention completion refetches same prefix after backend changes`() { - rpc.search = { query -> FileSearchResultDto(files = listOf(file("kilo/$query.kt"))) } - - complete("@main") - KiloFileSearchSettingsService.getInstance().setBackend(FileSearchBackendDto.INTELLIJ) - complete("@main") - - assertEquals(listOf("main", "main"), rpc.searchQueries) - assertEquals(listOf(FileSearchBackendDto.KILO, FileSearchBackendDto.INTELLIJ), rpc.searchBackends) - } - fun `test clearing mentions resets cached prefix result`() { rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt"))) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 6b143985b7..4c1b60ef21 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.ConfigTargetDto -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -46,7 +45,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var beforeGlobalConfigTarget: (suspend () -> Unit)? = null val fileCalls = CopyOnWriteArrayList>() val searchQueries = CopyOnWriteArrayList() - val searchBackends = CopyOnWriteArrayList() val opened = CopyOnWriteArrayList() val openedFiles = CopyOnWriteArrayList() val localConfigs = CopyOnWriteArrayList() @@ -83,15 +81,9 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return fileResolver?.invoke(path) ?: fileMatches } - override suspend fun searchFiles( - directory: String, - query: String, - limit: Int, - backend: FileSearchBackendDto, - ): FileSearchResultDto { + override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto { assertNotEdt("searchFiles") searchQueries.add(query) - searchBackends.add(backend) return search?.invoke(query) ?: searchResult } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index 7ea0cdd6f9..1e267a0729 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -1,7 +1,6 @@ package ai.kilocode.rpc import ai.kilocode.rpc.dto.ConfigTargetDto -import ai.kilocode.rpc.dto.FileSearchBackendDto import ai.kilocode.rpc.dto.FileSearchResultDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.ModelsWorkspaceDto @@ -49,13 +48,8 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Resolve [path] to matching files, scoped primarily to [directory]. */ suspend fun files(directory: String, path: String): List - /** Fuzzy file/folder search via the selected backend. */ - suspend fun searchFiles( - directory: String, - query: String, - limit: Int = 50, - backend: FileSearchBackendDto = FileSearchBackendDto.KILO, - ): FileSearchResultDto + /** Fuzzy file/folder search via Kilo Core. */ + suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ suspend fun gitChanges(directory: String): String? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt deleted file mode 100644 index a6565e7b75..0000000000 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/FileSearchBackendDto.kt +++ /dev/null @@ -1,13 +0,0 @@ -package ai.kilocode.rpc.dto - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -enum class FileSearchBackendDto { - @SerialName("kilo") - KILO, - - @SerialName("intellij") - INTELLIJ, -} From 6dcaeb3e97f256e9e98d5093610a0a02aedf84de Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 16 Jul 2026 14:53:45 -0400 Subject: [PATCH 06/33] feat(jetbrains): add context settings page --- .changeset/jetbrains-context-settings.md | 5 + .../plans/jetbrains-context-settings-page.md | 423 ++++++++++++++++++ docs/jetbrains-vscode-settings-parity.md | 87 ++++ .../kilocode/backend/cli/KiloCliDataParser.kt | 40 ++ .../backend/app/KiloBackendAppServiceTest.kt | 24 + .../backend/cli/KiloCliDataParserTest.kt | 40 ++ .../settings/KiloSettingsConfigurable.kt | 9 + .../settings/context/ContextConfigurable.kt | 18 + .../settings/context/ContextSettingsState.kt | 78 ++++ .../settings/context/ContextSettingsUi.kt | 300 +++++++++++++ .../resources/kilo.jetbrains.frontend.xml | 12 +- .../resources/messages/KiloBundle.properties | 21 + .../settings/KiloSettingsConfigurableTest.kt | 16 +- .../context/ContextSettingsStateTest.kt | 76 ++++ .../settings/context/ContextSettingsUiTest.kt | 201 +++++++++ .../kilocode/client/testing/FakeAppRpcApi.kt | 24 + .../ai/kilocode/rpc/dto/KiloAppStateDto.kt | 29 ++ 17 files changed, 1400 insertions(+), 3 deletions(-) create mode 100644 .changeset/jetbrains-context-settings.md create mode 100644 .kilo/plans/jetbrains-context-settings-page.md create mode 100644 docs/jetbrains-vscode-settings-parity.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt diff --git a/.changeset/jetbrains-context-settings.md b/.changeset/jetbrains-context-settings.md new file mode 100644 index 0000000000..c90bc7ffd8 --- /dev/null +++ b/.changeset/jetbrains-context-settings.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add JetBrains Context settings for compaction and file watcher ignore patterns. diff --git a/.kilo/plans/jetbrains-context-settings-page.md b/.kilo/plans/jetbrains-context-settings-page.md new file mode 100644 index 0000000000..1b76e7b6ce --- /dev/null +++ b/.kilo/plans/jetbrains-context-settings-page.md @@ -0,0 +1,423 @@ +# JetBrains Context Settings Page + +Implement the Tier 1 Context settings from `docs/jetbrains-vscode-settings-parity.md` in the JetBrains plugin. This is a pure `kilo.json` settings UI: no CLI feature work, no SDK regen, and no session-rendering changes. + +## Goal + +Add a new JetBrains settings page under `Settings -> Tools -> Kilo Code -> Context` for: + +| Setting | Config key | Type | +|---|---|---| +| Auto-compaction | `compaction.auto` | boolean | +| Compaction threshold percent | `compaction.threshold_percent` | number or null | +| Prune on compaction | `compaction.prune` | boolean | +| Watcher ignore patterns | `watcher.ignore` | string array | + +Do not include VS Code Context-tab memory/indexing controls in this first pass. JetBrains does not have the equivalent memory/indexing settings service yet, and the parity doc excludes indexing from easy wins. + +Do not put `snapshot` on this page unless product explicitly decides to combine Context and Checkpoints. The parity doc suggests `snapshot` belongs on a new Checkpoints page. + +## Context Verified + +- Source parity doc: `docs/jetbrains-vscode-settings-parity.md`. +- JetBrains settings guidance: `packages/kilo-jetbrains/AGENTS.md`, especially `Settings UI`. +- Existing settings pages are registered in `packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml`. +- Existing page pattern to mirror: + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsConfigurable.kt` + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsUi.kt` + - `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/models/ModelsSettingsState.kt` +- Existing global config write path is sufficient once DTO/parser support is added: + - Frontend: `KiloAppService.updateConfigAsync(...)` + - RPC: `KiloAppRpcApi.updateConfig(patch: ConfigPatchDto)` + - Backend: `KiloBackendAppService.updateConfig(...)` + - HTTP: `PATCH /global/config`, then `GET /global/config` +- Existing backend parser currently only serializes selected string keys from `ConfigPatchDto.values`; Context needs typed booleans, numbers, explicit null, and string arrays. + +## Decisions + +- Use global config for the first implementation, matching the existing app-level settings write path. +- Add typed DTO fields instead of overloading `ConfigPatchDto.values` for non-string values. +- Use an explicit `clear` list for nullable compaction fields, because `Double?` cannot distinguish absent from explicit `null`. +- Reuse `BaseSettingsUi`, `DraftReadyConfigurable`, `SettingsDraftState`, `SettingsRows`, `SettingsRow`, and `SettingsToggle`. +- Use the shared settings list primitives for `watcher.ignore`; do not build a bespoke add/remove list if `SettingsListPanel` or adjacent list primitives fit. +- Keep all UI strings in `KiloBundle.properties`. Let other locale bundles fall back unless the repo's resource-bundle checks require duplicated English keys. + +## Part A - Shared DTOs + +File: `packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt` + +Add config read DTOs: + +```kotlin +@Serializable +data class WatcherConfigDto( + val ignore: List = emptyList(), +) + +@Serializable +data class CompactionConfigDto( + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) +``` + +Extend `ConfigDto`: + +```kotlin +val watcher: WatcherConfigDto? = null, +val compaction: CompactionConfigDto? = null, +``` + +Add patch DTOs: + +```kotlin +@Serializable +data class WatcherPatchDto( + val ignore: List? = null, +) + +@Serializable +data class CompactionPatchDto( + val clear: List = emptyList(), + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) +``` + +Extend `ConfigPatchDto`: + +```kotlin +val watcher: WatcherPatchDto? = null, +val compaction: CompactionPatchDto? = null, +``` + +Notes: + +- `watcher.ignore = null` means no change. +- `watcher.ignore = emptyList()` means explicitly save an empty list. +- `compaction.threshold_percent = null` alone means no change. +- `compaction.clear = listOf("threshold_percent")` means emit JSON `"threshold_percent": null`. +- `false` boolean values must be serialized; do not treat `false` as absent. + +## Part B - Backend Config Parser And Serializer + +File: `packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt` + +### Parse + +Extend `parseConfig(raw)` to read: + +- `watcher.ignore` +- `compaction.auto` +- `compaction.threshold_percent` +- `compaction.prune` + +Add private helpers near `parseSkillsConfig` / `parseMcpConfig`: + +```kotlin +private fun parseWatcherConfig(obj: JsonObject?): WatcherConfigDto? +private fun parseCompactionConfig(obj: JsonObject?): CompactionConfigDto? +``` + +Use existing helper style: + +- strings: `str(...)` +- booleans: `flagOrNull(...)` +- numbers: `num(...)` +- arrays: `arr()?.mapNotNull { it.jsonPrimitive.contentOrNull }` + +### Serialize + +Extend `buildConfigPatch(patch)` to emit typed context patches: + +```json +{ + "watcher": { + "ignore": ["**/node_modules/**"] + }, + "compaction": { + "auto": true, + "threshold_percent": 80, + "prune": false + } +} +``` + +For explicit threshold clearing: + +```json +{ + "compaction": { + "threshold_percent": null + } +} +``` + +Keep the existing `values` allowlist for string model keys. Do not pass Context values through `values`. + +## Part C - Frontend State Model + +Add package: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/` + +New file: `ContextSettingsState.kt` + +Define: + +```kotlin +internal data class ContextDraft( + val auto: Boolean? = null, + val threshold: String = "", + val prune: Boolean? = null, + val ignore: List = emptyList(), +) +``` + +Use a string for the threshold draft so the UI can represent blank/invalid intermediate input without losing user text. Convert only when building a patch. + +Functions to add: + +- `contextDraft(config: ConfigDto?): ContextDraft` +- `patch(from: ContextDraft, to: ContextDraft): ConfigPatchDto` +- `savedMatches(base: ContextDraft, draft: ContextDraft): Boolean` +- `threshold(value: String): Double?` or equivalent parsing helper +- validation helper for threshold range if desired + +Patch behavior: + +- Only emit changed fields. +- Emit `CompactionPatchDto(auto = false)` when the user turns auto-compaction off. +- Emit `CompactionPatchDto(prune = false)` when the user turns pruning off. +- Emit `CompactionPatchDto(threshold_percent = 80.0)` for a non-blank valid number. +- Emit `CompactionPatchDto(clear = listOf("threshold_percent"))` when an existing threshold is cleared. +- Emit `WatcherPatchDto(ignore = emptyList())` when the last ignore pattern is removed. +- Return no change from the page when all fields match the baseline. + +## Part D - Frontend UI Page + +New file: `ContextConfigurable.kt` + +Mirror `ModelsConfigurable`: + +- Extend `DraftReadyConfigurable`. +- `ID = "ai.kilocode.jetbrains.settings.context"`. +- `getDisplayName()` returns `KiloBundle.message("settings.context.displayName")`. +- `create(cs)` returns `ContextSettingsUi(cs)`. + +New file: `ContextSettingsUi.kt` + +Mirror the simple parts of `ModelsSettingsUi`: + +- Extend `BaseSettingsUi`. +- Initial draft is `ContextDraft()`. +- `save(change, done)` calls `app.updateConfigAsync(change, done)`. +- `base(result)` and `draft(state)` call `contextDraft(state.config)`. +- `saved(base, draft)` calls `savedMatches(base, draft)`. +- `pendingText()` uses `settings.context.save.pending`. +- `failedText()` uses `settings.context.save.failed`. +- `loadWorkspace(root)` returns `Unit`; `applyWorkspace(result)` is `Unit`. +- `models(state)` is `Unit`. +- `syncContent()` updates enabled states, field values, save/progress overlay, and validation messaging. + +New content class: `ContextSettingsContent` + +Suggested layout: + +- Section `settings.context.compaction.title` + - Toggle row `settings.context.compaction.auto.title` + - Numeric row `settings.context.compaction.threshold.title` + - Toggle row `settings.context.compaction.prune.title` +- Section `settings.context.watcher.title` + - List editor row/panel for ignore patterns + +Controls: + +- Use `SettingsToggle` for booleans. +- Use `JBTextField` or a small reusable numeric field pattern based on `AgentEditDialog` for threshold. +- Use shared list primitives (`SettingsListPanel` / `SettingsListView` / `SettingsListItem` / `SettingsListCell`) for `watcher.ignore` where practical. +- Keep the page editable while app status is ready and no save is pending. +- Disable controls while saving. + +Validation: + +- Blank threshold is valid and means clear/reset the config value if it differs from baseline. +- Non-numeric threshold is invalid and should prevent `apply()` from sending a patch. +- Suggested accepted range is `0..100`; if existing CLI allows a broader range, follow CLI behavior. +- Show validation through existing settings messaging rather than custom ad hoc labels. + +## Part E - Settings Registration And Root Navigation + +File: `packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml` + +Add a child configurable: + +```xml + +``` + +Adjust weights so the desired order is stable. Recommended order: + +| Page | Weight | +|---|---| +| User Profile | 5 | +| Models | 4 | +| Context | 3 | +| Providers | 2 | +| Agent Behavior | 1 | + +File: `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt` + +Add a root-page `ActionLink` for Context between Models and Providers: + +- Import `ContextConfigurable`. +- Link text: `settings.context.displayName`. +- Link target: `ContextConfigurable.ID`. + +`KiloSettingsSelection.kt` probably needs no code changes because child IDs already share the root prefix. + +## Part F - Strings + +File: `packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties` + +Add base strings near the other settings strings: + +```properties +settings.context.displayName=Context +settings.context.description=Configure compaction and file-watcher context behavior. +settings.context.save.pending=Saving context settings... +settings.context.save.failed=Failed to save context settings +settings.context.compaction.title=Compaction +settings.context.compaction.description=Control when Kilo summarizes long sessions to reduce context usage. +settings.context.compaction.auto.title=Auto-compaction +settings.context.compaction.auto.description=Automatically compact long conversations before they exceed the model context window. +settings.context.compaction.threshold.title=Compaction threshold +settings.context.compaction.threshold.description=Percent of the context window to use before auto-compaction starts. Leave blank to use the default. +settings.context.compaction.threshold.invalid=Enter a number from 0 to 100, or leave the field blank. +settings.context.compaction.prune.title=Prune on compaction +settings.context.compaction.prune.description=Drop older raw conversation details after compaction to keep the session context smaller. +settings.context.watcher.title=Watcher ignore patterns +settings.context.watcher.description=Glob patterns Kilo should ignore when watching repository file changes. +settings.context.watcher.add=Add pattern +settings.context.watcher.empty=No ignore patterns configured. +settings.context.watcher.placeholder=e.g. **/dist/** +settings.context.watcher.remove=Remove {0} +``` + +If resource-bundle tests require every key in every locale bundle, copy English values into the localized bundles and leave translation work for a later i18n pass. + +## Part G - Test Updates + +### Frontend state tests + +Add: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt` + +Cover: + +- Draft reads `ConfigDto.watcher` and `ConfigDto.compaction`. +- Unchanged draft emits no patch. +- Boolean changes emit `false` and `true` correctly. +- Threshold set emits `threshold_percent`. +- Threshold clear emits `clear = listOf("threshold_percent")`. +- Watcher list add/remove emits the whole new `ignore` list, including empty list. +- Invalid threshold is rejected before save if validation lives in state helpers. + +### Frontend UI tests + +Add: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt` + +Use `ModelsSettingsUiTest` as the main pattern: + +- `BasePlatformTestCase`. +- Real EDT. +- `FakeAppRpcApi`. +- `KiloAppService`. +- `flushUntil` helpers. +- Assert `rpc.configPatches` after user interaction. +- Assert controls disable during pending save. +- Assert failed save leaves page modified and shows `settings.context.save.failed`. + +Update `FakeAppRpcApi`: + +- File: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt` +- Apply `patch.watcher` and `patch.compaction` to fake config state. +- Preserve explicit empty lists. +- Preserve boolean `false`. +- Honor `compaction.clear` by setting cleared fields to `null`. + +### Backend parser tests + +Update: `packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt` + +Add exact JSON tests for: + +- `parseConfig` reads watcher and compaction fields. +- `buildConfigPatch` emits watcher ignore arrays. +- `buildConfigPatch` emits `auto=false` and `prune=false`. +- `buildConfigPatch` emits numeric `threshold_percent`. +- `buildConfigPatch` emits explicit `threshold_percent:null` when `clear` includes the field. + +### Backend app service tests + +Update: `packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt` + +Add a test similar to the existing model config update test: + +- Call `updateConfig(ConfigPatchDto(watcher = ..., compaction = ...))`. +- Assert `MockCliServer.lastConfigPatchBody` exactly matches the expected nested JSON. +- Assert the returned/reloaded `ConfigDto` includes the saved Context values. + +### Root settings tests + +Update: `packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurableTest.kt` + +Add: + +- `ContextConfigurable.ID == "ai.kilocode.jetbrains.settings.context"`. +- Root page includes a Context link. +- Link order matches XML order. + +## Validation + +Run from `packages/kilo-jetbrains/`: + +```bash +./gradlew typecheck +./gradlew test +``` + +Focused checks while iterating: + +```bash +./gradlew :shared:test --tests '*ContextSettingsStateTest' +./gradlew :frontend:test --tests '*ContextSettingsUiTest' +./gradlew :backend:test --tests '*KiloCliDataParserTest' +./gradlew :backend:test --tests '*KiloBackendAppServiceTest' +``` + +If the exact Gradle module test selectors differ, run the package-level `./gradlew test` before marking the implementation ready. + +Manual verification: + +1. Run `./gradlew runIde` from `packages/kilo-jetbrains/`. +2. Open `Settings -> Tools -> Kilo Code -> Context`. +3. Toggle auto-compaction and prune. +4. Set threshold to a number, apply, reopen settings, and verify it persists. +5. Clear threshold, apply, reopen settings, and verify it resets. +6. Add and remove watcher ignore patterns, apply, reopen settings, and verify the list persists. +7. Inspect the global Kilo config file through the existing `Open: global ...` action if needed. + +## Risks And Follow-ups + +- Global vs project-local config: this plan uses the existing global config write path. Project-local Context settings would need new workspace config RPC plumbing. +- Threshold null semantics: implement explicit clear handling; otherwise clearing the field will silently do nothing. +- String-array UI: reuse list primitives even if it takes a small adapter type; avoid one-off list widgets. +- VS Code memory/indexing parity: defer because it is not pure config and is excluded by the easy-win criteria. +- Checkpoints page: implement `snapshot` separately unless product asks to combine it with Context. +- Changeset: when implementing this user-facing JetBrains settings feature, add a patch changeset for `kilo-code`/JetBrains according to repo release guidance. diff --git a/docs/jetbrains-vscode-settings-parity.md b/docs/jetbrains-vscode-settings-parity.md new file mode 100644 index 0000000000..fe55973eed --- /dev/null +++ b/docs/jetbrains-vscode-settings-parity.md @@ -0,0 +1,87 @@ +# JetBrains ↔ VS Code Settings Parity: Easy Wins + +## How parity works here + +Both clients edit the **same shared `kilo.json`** through the CLI. So any setting whose +behavior lives entirely in the CLI is an "easy win" for JetBrains: the CLI already does the +work, JetBrains just needs a UI row that writes the config key. No CLI changes, no new feature. + +Structural gap: today JetBrains only has **Models / Providers / Agent Behavior / Profile** +settings pages. There is **no General / Display / Experimental / Context / Checkpoints** page. +The lift for most easy wins is: + +1. Add a new `Configurable` page (using existing `settings/base/` primitives — + `BaseSettingsUi`, `SettingsRow`, `SettingsToggle`, `SettingsListPanel`), register it in + `kilo.jetbrains.frontend.xml`. +2. Extend the `buildConfigPatch` allowlist in `KiloCliDataParser.kt` (currently only + `model`, `small_model`, `subagent_model`, `subagent_variant`, `default_agent`) and add + boolean/number JSON serialization — it currently only emits strings. +3. Add localized labels to `KiloBundle.properties`. + +No CLI/SDK change and no new runtime feature. + +## Excluded from "easy" + +| Excluded | Reason | +|---|---| +| Agent Behavior, Auto-Approve | Skipped by request | +| Indexing, Sandboxing | Imply enabling new features | +| Browser Automation | Playwright feature not present in JetBrains | +| Autocomplete (provider/model/toggles) | No autocomplete feature (flags exist only as migration stubs) | +| Agent Manager (auto-branch, prefix) | VS Code-only feature | +| Notification/attention sounds | Client must implement sound playback | +| `maxCost` alert | Client must render the alert UI | +| Commit message (`commit_message.prompt`, `languageCommitMessage`) | No commit-message generation feature in JetBrains | +| `language`, `fontSize`, `diff.renderMarkdown`, `agentWorkStyle` | VS Code-webview/onboarding-specific | + +## Tier 1 — Genuinely easy (CLI does all the work; just add UI + config key) + +| Setting | Config key | Type | Suggested page | +|---|---|---|---| +| Hide prompt-training models | `hide_prompt_training_models` | bool | Models | +| Enable checkpoints | `snapshot` | bool | new "Checkpoints" | +| Auto-compaction | `compaction.auto` | bool | new "Context" | +| Compaction threshold % | `compaction.threshold_percent` | number | Context | +| Prune on compaction | `compaction.prune` | bool | Context | +| Watcher ignore patterns | `watcher.ignore` | string[] | Context (list editor) | +| Display username | `username` | string | new "Display/General" | +| Share mode | `share` | enum (manual/auto/disabled) | new "Experimental" | +| Remote control on startup | `remote_control` | bool | Experimental | +| Formatter integration | `formatter` | bool | Experimental | +| LSP integration | `lsp` | bool | Experimental | +| Batch tool | `experimental.batch_tool` | bool | Experimental | +| Native notebook tools | `experimental.native_notebook_tools` | bool | Experimental | +| Continue loop on deny | `experimental.continue_loop_on_deny` | bool | Experimental | +| SWE pruner (+ model) | `experimental.swe_pruner`, `..._model` | bool + string | Experimental | +| MCP timeout | `experimental.mcp_timeout` | number | Experimental | +| Per-tool toggles | `tools.` | bool | Experimental | + +**Claude Code compatibility**: lives under "Agent Behavior" in VS Code, but in JetBrains the +entire backend (`KiloClaudeCompatSettings` + RPC getter/setter + spawn-env wiring) already +exists with no UI. Exposing it is the single lowest-effort item — just a checkbox bound to the +existing RPC, no config plumbing. + +⚠️ Hold back `experimental.codebase_search` (leans on indexing) and +`experimental.image_generation` (adds a tool) — arguably "enabling a feature." + +## Tier 2 — Config is easy, but honoring it needs JetBrains rendering work + +| Setting | Config key | Extra work | +|---|---|---| +| Auto-collapse reasoning | `auto_collapse_reasoning` | Reasoning-card default collapse | +| Terminal command display | `terminal_command_display` (expanded/collapsed) | Tool-card default state | +| Code edit display | `code_edit_display` (expanded/collapsed) | Edit-card default state | + +## Recommendation + +Add a new **"General/Display" page + "Experimental" page** driven entirely by CLI config, +seeded with Tier 1 behavioral settings, plus wire the already-built **Claude Code compat** +toggle. This closes most of the non-feature gap with: + +- zero CLI/SDK changes, +- one allowlist extension in `KiloCliDataParser.buildConfigPatch` (add keys + boolean/number serialization), +- reuse of existing `settings/base/` UI primitives and test patterns (`FakeAppRpcApi` + frontend test + `MockCliServer` backend body assertion). + +Do Tier 2 (reasoning/terminal/edit display defaults) after Tier 1, since it touches the +session-rendering layer rather than being pure config. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index fee794849b..af023767ae 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -20,6 +20,7 @@ import ai.kilocode.rpc.dto.CommandDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomProviderSaveDto @@ -73,6 +74,7 @@ import ai.kilocode.rpc.dto.TodoDto import ai.kilocode.rpc.dto.TodoViewDto import ai.kilocode.rpc.dto.TokensDto import ai.kilocode.rpc.dto.ToolRefDto +import ai.kilocode.rpc.dto.WatcherConfigDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -521,6 +523,8 @@ object KiloCliDataParser { subagentModel = obj.str("subagent_model"), subagentVariant = obj.str("subagent_variant"), defaultAgent = obj.str("default_agent"), + watcher = parseWatcherConfig(obj["watcher"].obj()), + compaction = parseCompactionConfig(obj["compaction"].obj()), instructions = obj["instructions"].arr() ?.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } ?: emptyList(), @@ -530,6 +534,24 @@ object KiloCliDataParser { ) }.getOrDefault(ConfigDto()) + private fun parseWatcherConfig(obj: JsonObject?): WatcherConfigDto? { + if (obj == null) return null + return WatcherConfigDto( + ignore = obj["ignore"].arr() + ?.mapNotNull { runCatching { it.jsonPrimitive.contentOrNull }.getOrNull() } + ?: emptyList(), + ) + } + + private fun parseCompactionConfig(obj: JsonObject?): CompactionConfigDto? { + if (obj == null) return null + return CompactionConfigDto( + auto = obj.flagOrNull("auto"), + threshold_percent = obj.num("threshold_percent"), + prune = obj.flagOrNull("prune"), + ) + } + private fun parseSkillsConfig(obj: JsonObject?): SkillsConfigDto? { if (obj == null) return null return SkillsConfigDto( @@ -838,6 +860,24 @@ object KiloCliDataParser { val instructions = patch.instructions if (instructions != null) put("instructions", JsonArray(instructions.map(::JsonPrimitive))) + val watcher = patch.watcher + if (watcher != null) { + put("watcher", buildJsonObject { + val ignore = watcher.ignore + if (ignore != null) put("ignore", JsonArray(ignore.map(::JsonPrimitive))) + }) + } + + val compaction = patch.compaction + if (compaction != null) { + put("compaction", buildJsonObject { + for (field in compaction.clear) put(field, JsonNull) + if (compaction.auto != null) put("auto", compaction.auto) + if (compaction.threshold_percent != null) put("threshold_percent", compaction.threshold_percent) + if (compaction.prune != null) put("prune", compaction.prune) + }) + } + val skills = patch.skills if (skills != null) { put("skills", buildJsonObject { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt index f5d613cecb..f059fa8e0c 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt @@ -9,7 +9,9 @@ import ai.kilocode.backend.testing.FakeCliServer import ai.kilocode.backend.testing.MockCliServer import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.WatcherPatchDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -222,6 +224,28 @@ class KiloBackendAppServiceTest { assertEquals("fast", svc.config?.agent?.get("code")?.variant) } + @Test + fun `update config patches context settings and reloads`() = runBlocking { + val svc = create() + svc.connect() + ready(svc) + + val state = svc.updateConfig(ConfigPatchDto( + watcher = WatcherPatchDto(ignore = listOf("**/dist/**", "tmp/**")), + compaction = CompactionPatchDto(auto = false, threshold_percent = 75.5, prune = false), + )) + + assertEquals( + "{\"watcher\":{\"ignore\":[\"**/dist/**\",\"tmp/**\"]},\"compaction\":{\"auto\":false,\"threshold_percent\":75.5,\"prune\":false}}", + mock.lastConfigPatchBody, + ) + val cfg = appStateDto(state).config + assertEquals(listOf("**/dist/**", "tmp/**"), cfg?.watcher?.ignore) + assertEquals(false, cfg?.compaction?.auto) + assertEquals(75.5, cfg?.compaction?.threshold_percent) + assertEquals(false, svc.config?.compaction?.prune) + } + @Test fun `ready dto maps model config`() = runBlocking { mock.config = """{"model":"openai/gpt","agent":{"plan":{"model":"anthropic/claude","variant":"high"}}}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 74479094e7..de244ad0f8 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.backend.workspace.CommandInfo import ai.kilocode.backend.workspace.ProviderData import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.AgentConfigPatchDto +import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -19,6 +20,7 @@ import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto import ai.kilocode.rpc.dto.QuestionReplyDto import ai.kilocode.rpc.dto.SkillsPatchDto +import ai.kilocode.rpc.dto.WatcherPatchDto import org.junit.jupiter.api.Nested import kotlin.test.Test import kotlin.test.assertEquals @@ -1173,6 +1175,21 @@ class KiloCliDataParserTest { assertEquals(listOf("https://example.test/skill.md"), cfg.skills?.urls) } + @Test + fun `parseConfig - context settings`() { + val cfg = KiloCliDataParser.parseConfig( + """{ + "watcher":{"ignore":["**/dist/**","tmp/**"]}, + "compaction":{"auto":true,"threshold_percent":75.5,"prune":false} + }""" + ) + + assertEquals(listOf("**/dist/**", "tmp/**"), cfg.watcher?.ignore) + assertEquals(true, cfg.compaction?.auto) + assertEquals(75.5, cfg.compaction?.threshold_percent) + assertEquals(false, cfg.compaction?.prune) + } + @Test fun `parseConfig - agent overrides and permissions`() { val cfg = KiloCliDataParser.parseConfig( @@ -2123,6 +2140,29 @@ class KiloCliDataParserTest { ) } + @Test + fun `buildConfigPatch - context watcher and compaction fields`() { + val patch = ConfigPatchDto( + watcher = WatcherPatchDto(ignore = listOf("**/dist/**", "tmp/**")), + compaction = CompactionPatchDto(auto = false, threshold_percent = 75.5, prune = false), + ) + + assertEquals( + "{\"watcher\":{\"ignore\":[\"**/dist/**\",\"tmp/**\"]},\"compaction\":{\"auto\":false,\"threshold_percent\":75.5,\"prune\":false}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + + @Test + fun `buildConfigPatch - context threshold clear emits null`() { + val patch = ConfigPatchDto(compaction = CompactionPatchDto(clear = listOf("threshold_percent"))) + + assertEquals( + "{\"compaction\":{\"threshold_percent\":null}}", + KiloCliDataParser.buildConfigPatch(patch), + ) + } + @Test fun `buildConfigPatch - mcp upsert and delete`() { val patch = ConfigPatchDto(mcp = linkedMapOf( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index 954d62a685..1f441f1602 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.agents.AgentBehaviorConfigurable +import ai.kilocode.client.settings.context.ContextConfigurable import ai.kilocode.client.settings.models.ModelsConfigurable import ai.kilocode.client.settings.providers.ProvidersConfigurable import ai.kilocode.client.settings.profile.UserProfileConfigurable @@ -57,6 +58,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { models.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(models) + val context = ActionLink(KiloBundle.message("settings.context.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, ContextConfigurable.ID) + } + context.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + panel.next(context) + val providers = ActionLink(KiloBundle.message("settings.providers.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt new file mode 100644 index 0000000000..2a5c92916e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextConfigurable.kt @@ -0,0 +1,18 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.DraftReadyConfigurable +import kotlinx.coroutines.CoroutineScope +import javax.swing.JComponent + +class ContextConfigurable : DraftReadyConfigurable() { + override fun getId(): String = ID + + override fun getDisplayName(): String = KiloBundle.message("settings.context.displayName") + + override fun create(cs: CoroutineScope): JComponent = ContextSettingsUi(cs) + + companion object { + const val ID = "ai.kilocode.jetbrains.settings.context" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt new file mode 100644 index 0000000000..d703e75be1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt @@ -0,0 +1,78 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.rpc.dto.CompactionPatchDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.WatcherPatchDto + +internal data class ContextDraft( + val auto: Boolean = false, + val threshold: String = "", + val prune: Boolean = false, + val ignore: List = emptyList(), +) + +internal enum class ThresholdStatus { + VALID, + INVALID, +} + +internal fun contextDraft(config: ConfigDto?): ContextDraft = ContextDraft( + auto = config?.compaction?.auto ?: false, + threshold = config?.compaction?.threshold_percent?.let(::formatThreshold).orEmpty(), + prune = config?.compaction?.prune ?: false, + ignore = config?.watcher?.ignore ?: emptyList(), +) + +internal fun patch(from: ContextDraft, to: ContextDraft): ConfigPatchDto { + if (thresholdStatus(to.threshold) == ThresholdStatus.INVALID) return ConfigPatchDto() + + val compaction = compactionPatch(from, to) + val watcher = if (from.ignore != to.ignore) WatcherPatchDto(ignore = to.ignore) else null + return ConfigPatchDto(watcher = watcher, compaction = compaction) +} + +internal fun changed(patch: ConfigPatchDto): Boolean = patch.watcher != null || patch.compaction != null + +internal fun savedMatches(base: ContextDraft, draft: ContextDraft): Boolean = + base.auto == draft.auto && + normalizeThreshold(base.threshold) == normalizeThreshold(draft.threshold) && + base.prune == draft.prune && + base.ignore == draft.ignore + +internal fun thresholdStatus(value: String): ThresholdStatus { + val text = value.trim() + if (text.isBlank()) return ThresholdStatus.VALID + val num = text.toDoubleOrNull() + if (num == null || !num.isFinite() || num < 0.0 || num > 100.0) return ThresholdStatus.INVALID + return ThresholdStatus.VALID +} + +private fun compactionPatch(from: ContextDraft, to: ContextDraft): CompactionPatchDto? { + val clear = mutableListOf() + val threshold = parseThreshold(to.threshold) + val fromThreshold = parseThreshold(from.threshold) + if (fromThreshold != threshold && threshold == null) clear += "threshold_percent" + val patch = CompactionPatchDto( + clear = clear, + auto = to.auto.takeIf { from.auto != to.auto }, + threshold_percent = threshold.takeIf { fromThreshold != threshold && threshold != null }, + prune = to.prune.takeIf { from.prune != to.prune }, + ) + if (patch.clear.isEmpty() && patch.auto == null && patch.threshold_percent == null && patch.prune == null) return null + return patch +} + +private fun parseThreshold(value: String): Double? { + val text = value.trim() + if (text.isBlank()) return null + return text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 && it <= 100.0 } +} + +private fun normalizeThreshold(value: String): String = parseThreshold(value)?.let(::formatThreshold).orEmpty() + +private fun formatThreshold(value: Double): String { + val whole = value.toLong() + if (value == whole.toDouble()) return whole.toString() + return value.toString() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt new file mode 100644 index 0000000000..570b860b6d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -0,0 +1,300 @@ +package ai.kilocode.client.settings.context + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.BaseSettingsUi +import ai.kilocode.client.settings.base.SettingsBannerKind +import ai.kilocode.client.settings.base.SettingsRow +import ai.kilocode.client.settings.base.SettingsStackedRow +import ai.kilocode.client.settings.base.SettingsToggle +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.StackAxis +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.ModelStateDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.components.service +import com.intellij.ui.CollectionListModel +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBTextField +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CoroutineScope +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent +import javax.swing.text.AbstractDocument +import javax.swing.text.AttributeSet +import javax.swing.text.DocumentFilter + +internal class ContextSettingsUi( + cs: CoroutineScope, + private val app: KiloAppService = service(), + workspaces: KiloWorkspaceService = service(), +) : BaseSettingsUi( + cs, + ContextDraft(), + app, + workspaces, + loginBanner = false, +) { + init { + startSettings(ContextSettingsContent { updateDraft(it) }) + } + + override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to).takeIf(::changed) + + override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { + app.updateConfigAsync(change, done) + } + + override fun base(result: KiloAppStateDto): ContextDraft = contextDraft(result.config) + + override fun draft(state: KiloAppStateDto): ContextDraft = contextDraft(state.config) + + override fun saved(base: ContextDraft, draft: ContextDraft): Boolean = savedMatches(base, draft) + + override fun pendingText(): String = KiloBundle.message("settings.context.save.pending") + + override fun failedText(): String = KiloBundle.message("settings.context.save.failed") + + override suspend fun loadWorkspace(root: String) = Unit + + override fun applyWorkspace(result: Unit) = Unit + + override fun models(state: ModelStateDto) = Unit + + override fun logSaveStarted(change: ConfigPatchDto) = LOG.info("context settings save: started ${summary(change)}") + + override fun logSaveCompleted(change: ConfigPatchDto) = LOG.info("context settings save: completed ${summary(change)}") + + override fun logSaveFailed(change: ConfigPatchDto) = LOG.warn("context settings save: failed ${summary(change)}") + + override fun logSaveFailedAfterDispose(change: ConfigPatchDto) = LOG.warn("context settings save: failed after dispose ${summary(change)}") + + override fun logSaveCompletedAfterDispose(change: ConfigPatchDto) = LOG.info("context settings save: completed after dispose ${summary(change)}") + + @RequiresEdt + override fun syncContent() { + val ready = appState.status == KiloAppStatusDto.READY + val editable = ready && !saving + form.sync(draft, editable) + top.hideBanner() + val err = saveError + if (saving) { + showProgress(KiloBundle.message("settings.context.save.pending")) + return + } + if (err != null) { + showError(err) + return + } + if (!ready) { + showProgress(KiloBundle.message("settings.cli.unavailable.message")) + return + } + if (thresholdStatus(draft.threshold) == ThresholdStatus.INVALID) { + top.showBanner( + KiloBundle.message("settings.context.compaction.threshold.invalid"), + emptyList(), + SettingsBannerKind.ERROR, + ) + clearProgress() + return + } + clearProgress() + } + + private companion object { + val LOG = KiloLog.create(ContextSettingsUi::class.java) + } +} + +internal class ContextSettingsContent( + private val update: (ContextDraft.() -> ContextDraft) -> Unit, +) : BaseContentPanel() { + private val auto = SettingsToggle { value -> update { copy(auto = value) } } + private val prune = SettingsToggle { value -> update { copy(prune = value) } } + private val threshold = ThresholdField { value -> update { copy(threshold = value) } } + private val patterns = PatternList { value -> update { copy(ignore = value) } } + + init { + section( + KiloBundle.message("settings.context.compaction.title"), + KiloBundle.message("settings.context.compaction.description"), + ).apply { + row(SettingsRow( + KiloBundle.message("settings.context.compaction.auto.title"), + KiloBundle.message("settings.context.compaction.auto.description"), + auto, + )) + row(SettingsRow( + KiloBundle.message("settings.context.compaction.threshold.title"), + KiloBundle.message("settings.context.compaction.threshold.description"), + threshold.align(HAlign.RIGHT, VAlign.CENTER), + )) + row(SettingsRow( + KiloBundle.message("settings.context.compaction.prune.title"), + KiloBundle.message("settings.context.compaction.prune.description"), + prune, + )) + } + section( + KiloBundle.message("settings.context.watcher.title"), + KiloBundle.message("settings.context.watcher.description"), + ).row(SettingsStackedRow( + KiloBundle.message("settings.context.watcher.patterns.title"), + KiloBundle.message("settings.context.watcher.patterns.description"), + patterns, + )) + } + + @RequiresEdt + fun sync(draft: ContextDraft, enabled: Boolean) { + auto.isSelected = draft.auto + prune.isSelected = draft.prune + threshold.sync(draft.threshold) + patterns.sync(draft.ignore) + listOf(auto, prune, threshold, patterns).forEach { it.isEnabled = enabled } + } +} + +private class ThresholdField( + private val change: (String) -> Unit, +) : JBTextField() { + private var syncing = false + + init { + columns = THRESHOLD_COLUMNS + emptyText.text = KiloBundle.message("settings.context.compaction.threshold.placeholder") + (document as AbstractDocument).documentFilter = NumberFilter() + document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + if (!syncing) change(text) + } + }) + } + + fun sync(value: String) { + if (text == value) return + syncing = true + text = value + syncing = false + } +} + +private class NumberFilter : DocumentFilter() { + override fun insertString(fb: FilterBypass, offset: Int, string: String?, attr: AttributeSet?) { + replace(fb, offset, 0, string, attr) + } + + override fun replace(fb: FilterBypass, offset: Int, length: Int, text: String?, attrs: AttributeSet?) { + val value = text ?: "" + val next = StringBuilder(fb.document.getText(0, fb.document.length)) + .replace(offset, offset + length, value) + .toString() + if (next.isEmpty() || valid(next)) super.replace(fb, offset, length, value, attrs) + } + + private fun valid(value: String): Boolean { + if (value.count { it == '.' } > 1) return false + return value.all { it.isDigit() || it == '.' } + } +} + +internal class PatternList( + private val change: (List) -> Unit, +) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()) { + private val model = CollectionListModel() + internal val entry = JBTextField().apply { + emptyText.text = KiloBundle.message("settings.context.watcher.placeholder") + } + private val add = JButton(KiloBundle.message("settings.context.watcher.add"), AllIcons.General.Add).apply { + addActionListener { add() } + } + private val list = JBList(model).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + emptyText.text = KiloBundle.message("settings.context.watcher.empty") + } + private val panel = ToolbarDecorator.createDecorator(list) + .disableUpDownActions() + .disableAddAction() + .setRemoveAction { remove() } + .setRemoveActionUpdater { isEnabled && list.selectedIndex >= 0 } + .createPanel() + + init { + entry.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) = syncAdd() + }) + entry.registerKeyboardAction( + { add() }, + javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + next(Stack.horizontal(UiStyle.Gap.sm()).next(entry).next(add)) + next(panel) + syncAdd() + } + + @RequiresEdt + fun sync(values: List) { + if (model.items == values) return + model.replaceAll(values) + syncAdd() + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + entry.isEnabled = enabled + add.isEnabled = enabled && entry.text.trim().isNotBlank() + list.isEnabled = enabled + panel.isEnabled = enabled + syncAdd() + } + + private fun add() { + val value = entry.text.trim() + if (!isEnabled || value.isBlank()) return + val values = model.items.toMutableList() + if (value !in values) values += value + entry.text = "" + model.replaceAll(values) + change(values) + syncAdd() + } + + private fun remove() { + val idx = list.selectedIndex + if (!isEnabled || idx < 0 || idx >= model.size) return + val values = model.items.toMutableList() + values.removeAt(idx) + model.replaceAll(values) + change(values) + syncAdd() + } + + private fun syncAdd() { + add.isEnabled = isEnabled && entry.text.trim().isNotBlank() + } +} + +private fun summary(patch: ConfigPatchDto): String { + val parts = listOfNotNull( + "watcher".takeIf { patch.watcher != null }, + "compaction".takeIf { patch.compaction != null }, + ) + return parts.joinToString(",").ifEmpty { "none" } +} + +private const val THRESHOLD_COLUMNS = 8 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index d08df1f9f9..2d65979af4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -33,7 +33,7 @@ @@ -41,11 +41,19 @@ + + () + toggles[0].doClick() + toggles[1].doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + val patch = rpc.configPatches.single() + assertEquals(false, patch.compaction?.auto) + assertEquals(false, patch.compaction?.prune) + } + + fun `test editing threshold sends number`() { + val panel = requireUi() + + edt { + threshold(panel).text = "80" + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(80.0, rpc.configPatches.single().compaction?.threshold_percent) + } + + fun `test clearing threshold sends clear patch`() { + val panel = requireUi() + + edt { + threshold(panel).text = "" + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("threshold_percent"), rpc.configPatches.single().compaction?.clear) + } + + fun `test adding watcher pattern sends full list`() { + val panel = requireUi() + + edt { + val list = components(panel).filterIsInstance().single() + list.entry.text = "**/dist/**" + buttons(panel).single { it.text == "Add pattern" }.doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("tmp/**", "**/dist/**"), rpc.configPatches.single().watcher?.ignore) + } + + fun `test failed apply stays visible while panel open`() { + val panel = requireUi() + rpc.configUpdateError = RuntimeException("save failed") + + edt { + threshold(panel).text = "80" + panel.applyDraft() + } + + flushUntil { text(panel).contains("Failed to save context settings") } + edt { + assertTrue(text(panel.progress).contains("Failed to save context settings")) + assertTrue(panel.modified()) + } + } + + fun `test controls are disabled during pending save`() { + val panel = requireUi() + rpc.configUpdateGate = CompletableDeferred() + + edt { + threshold(panel).text = "80" + panel.applyDraft() + assertTrue(components(panel).filterIsInstance().all { !it.isEnabled }) + assertFalse(threshold(panel).isEnabled) + } + + rpc.configUpdateGate?.complete(Unit) + flushUntil { rpc.configPatches.isNotEmpty() } + } + + private fun requireUi(): ContextSettingsUi = requireNotNull(ui) + + private fun threshold(panel: ContextSettingsUi): JTextField = components(panel) + .filterIsInstance() + .single { it.columns == 8 } + + private fun buttons(panel: ContextSettingsUi): List = components(panel).filterIsInstance() + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(200) { + delay(10) + edt { UIUtil.dispatchAllInvocationEvents() } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(done()) + } + + private fun text(root: Container): String { + val out = mutableListOf() + for (comp in components(root)) { + if (!comp.isVisible) continue + when (comp) { + is AbstractButton -> comp.text?.let { out.add(it) } + is JLabel -> comp.text?.let { out.add(it) } + is JTextComponent -> comp.text?.let { out.add(it) } + is JTextField -> comp.text?.let { out.add(it) } + } + } + return out.joinToString("\n") + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } +} 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 ed6e3e928f..57868aadf0 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 @@ -2,6 +2,7 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloAppRpcApi import ai.kilocode.rpc.dto.AgentConfigDto +import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.DeviceAuthDto @@ -16,6 +17,7 @@ import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.SkillsConfigDto import ai.kilocode.rpc.dto.TelemetryCaptureDto +import ai.kilocode.rpc.dto.WatcherConfigDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -196,12 +198,34 @@ class FakeAppRpcApi : KiloAppRpcApi { val mcp = patch.mcp?.entries?.fold(config.mcp) { acc, (name, item) -> if (item == null) acc - name else acc + (name to item) } ?: config.mcp + val watcher = patch.watcher?.let { item -> + val cfg = config.watcher + cfg?.copy(ignore = item.ignore ?: cfg.ignore) + ?: WatcherConfigDto(ignore = item.ignore ?: emptyList()) + } ?: config.watcher + val compaction = patch.compaction?.let { item -> + val cfg = item.clear.fold(config.compaction ?: CompactionConfigDto()) { next, field -> + when (field) { + "threshold_percent" -> next.copy(threshold_percent = null) + "auto" -> next.copy(auto = null) + "prune" -> next.copy(prune = null) + else -> next + } + } + cfg.copy( + auto = item.auto ?: cfg.auto, + threshold_percent = item.threshold_percent ?: cfg.threshold_percent, + prune = item.prune ?: cfg.prune, + ) + } ?: config.compaction return config.copy( defaultAgent = if (values.containsKey("default_agent")) values["default_agent"] else config.defaultAgent, model = if (values.containsKey("model")) values["model"] else config.model, smallModel = if (values.containsKey("small_model")) values["small_model"] else config.smallModel, subagentModel = if (values.containsKey("subagent_model")) values["subagent_model"] else config.subagentModel, subagentVariant = if (values.containsKey("subagent_variant")) values["subagent_variant"] else config.subagentVariant, + watcher = watcher, + compaction = compaction, instructions = patch.instructions ?: config.instructions, skills = patch.skills?.let { SkillsConfigDto(paths = it.paths.orEmpty(), urls = it.urls.orEmpty()) } ?: config.skills, mcp = mcp, diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt index 4667d8f7b1..1ab2f302bd 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloAppStateDto.kt @@ -64,12 +64,26 @@ data class ConfigDto( val subagentModel: String? = null, val subagentVariant: String? = null, val defaultAgent: String? = null, + val watcher: WatcherConfigDto? = null, + val compaction: CompactionConfigDto? = null, val instructions: List = emptyList(), val skills: SkillsConfigDto? = null, val mcp: Map = emptyMap(), val agent: Map = emptyMap(), ) +@Serializable +data class WatcherConfigDto( + val ignore: List = emptyList(), +) + +@Serializable +data class CompactionConfigDto( + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) + @Serializable data class SkillsConfigDto( val paths: List = emptyList(), @@ -109,12 +123,27 @@ sealed class PermissionRuleDto { @Serializable data class ConfigPatchDto( val values: Map = emptyMap(), + val watcher: WatcherPatchDto? = null, + val compaction: CompactionPatchDto? = null, val instructions: List? = null, val skills: SkillsPatchDto? = null, val mcp: Map? = null, val agents: Map = emptyMap(), ) +@Serializable +data class WatcherPatchDto( + val ignore: List? = null, +) + +@Serializable +data class CompactionPatchDto( + val clear: List = emptyList(), + val auto: Boolean? = null, + val threshold_percent: Double? = null, + val prune: Boolean? = null, +) + @Serializable data class AgentConfigPatchDto( val clear: List = emptyList(), From a9a9b78b97290e855cda3dd7118a429503802396 Mon Sep 17 00:00:00 2001 From: kirillk Date: Thu, 16 Jul 2026 15:08:17 -0400 Subject: [PATCH 07/33] feat(jetbrains): add skills settings page --- .changeset/jetbrains-skills-settings.md | 5 + .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 2 +- .../rpc/KiloAgentBehaviorRpcApiImplTest.kt | 23 ++ .../kilocode/backend/testing/MockCliServer.kt | 6 + .../client/app/KiloWorkspaceService.kt | 9 + .../agents/AgentBehaviorConfigurable.kt | 1 + .../settings/agents/SkillsConfigurable.kt | 306 +++++++++++++++++ .../client/settings/base/SettingsListModel.kt | 2 + .../settings/base/SettingsListRenderer.kt | 3 + .../client/settings/base/SettingsListView.kt | 2 +- .../resources/kilo.jetbrains.frontend.xml | 7 + .../resources/messages/KiloBundle.properties | 16 + .../agents/AgentBehaviorConfigurableTest.kt | 3 +- .../settings/agents/SkillsSettingsUiTest.kt | 320 ++++++++++++++++++ .../client/testing/FakeAgentBehaviorRpcApi.kt | 13 +- 15 files changed, 713 insertions(+), 5 deletions(-) create mode 100644 .changeset/jetbrains-skills-settings.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/SkillsSettingsUiTest.kt diff --git a/.changeset/jetbrains-skills-settings.md b/.changeset/jetbrains-skills-settings.md new file mode 100644 index 0000000000..421979b1b8 --- /dev/null +++ b/.changeset/jetbrains-skills-settings.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add a Skills settings page in JetBrains for viewing, opening, deleting, and configuring skill sources. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 7d0311b6c6..3063f81a38 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -311,7 +311,7 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } descriptor.navigate(true) if (cont.isActive) cont.resume(Unit) - }, ModalityState.any()) + }, ModalityState.current()) } private fun project(path: Path): Project? { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt index 4050343723..af12fd4439 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloAgentBehaviorRpcApiImplTest.kt @@ -78,6 +78,29 @@ class KiloAgentBehaviorRpcApiImplTest { assertContains(err.message.orEmpty(), "HTTP 400") } + @Test + fun `skills and remove skill call CLI endpoints`() = runBlocking { + mock.skills = """[ + {"name":"plan","description":"Plan work","location":"/tmp/skill/SKILL.md"}, + {"name":"builtin","location":"builtin"} + ]""".trimIndent() + val rpc = rpc() + + val skills = rpc.skills("/test project") + assertEquals(listOf("plan", "builtin"), skills.map { it.name }) + assertEquals("Plan work", skills.single { it.name == "plan" }.description) + + assertTrue(rpc.removeSkill("/test project", "/tmp/skill/SKILL.md")) + assertEquals("{\"location\":\"/tmp/skill/SKILL.md\"}", mock.lastSkillRemoveBody) + assertEquals(1, mock.requestCount("/kilocode/skill/remove")) + + mock.skillRemoveStatus = 400 + val err = assertFailsWith { + rpc.removeSkill("/test", "/tmp/missing/SKILL.md") + } + assertContains(err.message.orEmpty(), "HTTP 400") + } + @Test fun `mcp config writes global and workspace patches`() = runBlocking { mock.config = """{"mcp":{"global":{"type":"local","command":["node","g.js"]}}}""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 888b07e113..b0771572ff 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -67,9 +67,11 @@ class MockCliServer : AutoCloseable { @Volatile var mcpStatus = 200 @Volatile var mcpActionStatus = 200 @Volatile var agentRemoveStatus = 200 + @Volatile var skillRemoveStatus = 200 @Volatile var agentBuilderStatus = 200 @Volatile var lastMcpActionPath: String? = null @Volatile var lastAgentRemoveBody: String? = null + @Volatile var lastSkillRemoveBody: String? = null @Volatile var lastAgentBuilderPath: String? = null @Volatile var lastAgentBuilderBody: String? = null @Volatile var lastAgentBuilderMethod: String? = null @@ -368,6 +370,10 @@ class MockCliServer : AutoCloseable { lastAgentRemoveBody = body respond(output, agentRemoveStatus, if (agentRemoveStatus == 200) "true" else """{"error":"Agent not found"}""") } + bare == "/kilocode/skill/remove" && method == "POST" -> { + lastSkillRemoveBody = body + respond(output, skillRemoveStatus, if (skillRemoveStatus == 200) "true" else """{"error":"Skill not found"}""") + } bare == "/command" -> respond(output, commandsStatus, commands) bare == "/skill" -> respond(output, skillsStatus, skills) bare == "/mcp" -> respond(output, mcpStatus, mcp) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 5836ae0cea..d443bf0147 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -168,6 +168,15 @@ class KiloWorkspaceService internal constructor( } } + suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean { + return try { + call { openFile(path, line, column) } + } catch (e: Exception) { + LOG.warn("workspace file open failed for path=$path", e) + false + } + } + suspend fun localConfigTarget(directory: String): ConfigTargetDto? { return try { val target = call { this.localConfigTarget(directory) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt index 800f9e26a8..e127e610da 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/AgentBehaviorConfigurable.kt @@ -25,6 +25,7 @@ class AgentBehaviorConfigurable : SearchableConfigurable { listOf( KiloBundle.message("settings.agentBehavior.agents.displayName") to AgentsConfigurable.ID, KiloBundle.message("settings.agentBehavior.mcp.displayName") to McpConfigurable.ID, + KiloBundle.message("settings.agentBehavior.skills.displayName") to SkillsConfigurable.ID, ).forEach { (label, id) -> panel.next(ActionLink(label) { e -> val src = e.source as? JComponent ?: return@ActionLink diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt new file mode 100644 index 0000000000..ddfd190f6a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/agents/SkillsConfigurable.kt @@ -0,0 +1,306 @@ +package ai.kilocode.client.settings.agents + +import ai.kilocode.client.app.KiloAgentBehaviorService +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.settings.base.SettingsBadge +import ai.kilocode.client.settings.base.SettingsListCell +import ai.kilocode.client.settings.base.SettingsListConfig +import ai.kilocode.client.settings.base.SettingsListItem +import ai.kilocode.client.settings.base.SettingsListPanel +import ai.kilocode.client.settings.base.SettingsListSelection +import ai.kilocode.client.settings.base.SettingsListView +import ai.kilocode.client.settings.base.SettingsMessageException +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ConfigPatchDto +import ai.kilocode.rpc.dto.SkillsConfigDto +import ai.kilocode.rpc.dto.SkillsPatchDto +import ai.kilocode.rpc.dto.SkillDto +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.components.service +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptor +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.BorderLayout +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants + +private val edt = Dispatchers.EDT + ModalityState.any().asContextElement() + +class SkillsConfigurable : AgentBehaviorConfigurableBase() { + override fun getId(): String = ID + override fun getDisplayName(): String = KiloBundle.message("settings.agentBehavior.skills.displayName") + override fun create(cs: CoroutineScope, dir: String): JComponent = SkillsSettingsUi(cs, dir) + override fun update(ui: JComponent, dir: String) { + (ui as? SkillsSettingsUi)?.setDirectory(dir) + } + override fun scrollReadyShell() = false + + companion object { const val ID = "ai.kilocode.jetbrains.settings.agentBehavior.skills" } +} + +internal class SkillsSettingsUi( + cs: CoroutineScope, + dir: String, + private val choose: (JComponent) -> String? = ::chooseSkillPath, + private val input: (String, String) -> String? = ::inputSkillUrl, +) : SettingsListPanel(cs, SettingsListConfig.Equal.copy(tooltip = false)) { + private var dir = dir + private var skills = emptyMap() + internal val sources = SkillSourcesView(this, choose, input) + + init { + start() + content.add(sources, BorderLayout.SOUTH) + } + + fun setDirectory(value: String) { + if (value == dir) return + dir = value + reload() + } + + override suspend fun fetch(): List { + val items = service().skills(dir) + val config = config() + withContext(edt) { + skills = items.associateBy { key(it) } + sources.refresh(config) + } + LOG.info("skills settings fetch dir=$dir total=${items.size}") + return items.map(::item) + } + + override fun afterApply() { + sources.refresh(config()) + } + + override fun onCell(key: String, cellId: String) { + val skill = skills[key] ?: return + when (cellId) { + OPEN_CELL -> open(skill) + DELETE_CELL -> remove(skill) + } + } + + override fun searchPlaceholder() = KiloBundle.message("settings.agentBehavior.skills.search") + + override fun emptyText() = KiloBundle.message("settings.agentBehavior.skills.empty") + + internal fun updateSources(paths: List, urls: List) { + mutateAndReload(SettingsListSelection.Preserve, KiloBundle.message("settings.agentBehavior.saving")) { + val patch = ConfigPatchDto(skills = SkillsPatchDto(paths = paths, urls = urls)) + if (service().updateConfig(patch) == null) { + throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.save.failed")) + } + true + } + } + + private fun item(skill: SkillDto) = object : SettingsListItem { + override val key = key(skill) + override val title = skill.name + override val note = skill.location.takeUnless { builtin(it) } + override val description = skill.description + override val badges = listOf( + SettingsBadge(KiloBundle.message("settings.agentBehavior.badge.builtin"), UiStyle.Badge.Secondary), + ).takeIf { builtin(skill.location) } ?: emptyList() + override val cells = if (builtin(skill.location)) emptyList() else listOf( + SettingsListCell( + OPEN_CELL, + KiloBundle.message("settings.agentBehavior.skills.open"), + primary = true, + ), + SettingsListCell( + DELETE_CELL, + KiloBundle.message("common.delete"), + icon = AllIcons.Actions.GC, + iconOnly = true, + ), + ) + } + + private fun open(skill: SkillDto) { + launch("open") { id -> + service().openFile(skill.location) + finishOpen(id) + } + } + + private suspend fun finishOpen(id: Int) { + withContext(edt) { + if (!active(id)) return@withContext + setBusy(false) + clearProgress() + } + } + + private fun remove(skill: SkillDto) { + val result = Messages.showYesNoDialog( + KiloBundle.message("settings.agentBehavior.skills.delete.message", skill.name), + KiloBundle.message("settings.agentBehavior.skills.delete.title"), + KiloBundle.message("common.delete"), + Messages.getCancelButton(), + Messages.getQuestionIcon(), + ) + if (result != Messages.YES) return + mutateAndReload(selectionIndex()) { + if (!service().removeSkill(dir, skill.location)) { + throw SettingsMessageException(KiloBundle.message("settings.agentBehavior.skills.delete.failed")) + } + true + } + } + + private fun config() = service().state.value.config?.skills ?: SkillsConfigDto() + + private companion object { + const val OPEN_CELL = "open" + const val DELETE_CELL = "delete" + const val BUILTIN = "builtin" + const val LEGACY_BUILTIN = "" + val LOG = KiloLog.create(SkillsSettingsUi::class.java) + + fun key(skill: SkillDto) = skill.location.ifBlank { skill.name } + fun builtin(location: String) = location == BUILTIN || location == LEGACY_BUILTIN + } +} + +internal class SkillSourcesView( + private val parent: SkillsSettingsUi, + private val choose: (JComponent) -> String?, + private val input: (String, String) -> String?, +) : Stack(ai.kilocode.client.ui.layout.StackAxis.VERTICAL, UiStyle.Gap.sm()) { + private val view = SettingsListView( + KiloBundle.message("settings.agentBehavior.skills.sources.empty"), + SettingsListConfig.Preferred.copy(description = false), + ) { key, id -> + if (id == DELETE_CELL) remove(key) + } + private var cfg = SkillsConfigDto() + + internal fun sourceList() = view.list + + init { + border = JBUI.Borders.compound( + JBUI.Borders.customLineTop(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground()), + JBUI.Borders.empty(UiStyle.Gap.pad(), 0, 0, 0), + ) + next(toolbar()) + next(JBScrollPane(view).apply { + border = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + preferredSize = JBUI.size(0, JBUI.scale(160)) + maximumSize = JBUI.size(Int.MAX_VALUE, JBUI.scale(160)) + }) + } + + fun refresh(config: SkillsConfigDto) { + cfg = config + view.update(rows(config)) + } + + private fun toolbar(): JComponent { + val group = DefaultActionGroup(AddPathAction(), AddUrlAction()) + val toolbar = ActionManager.getInstance().createActionToolbar(ActionPlaces.TOOLBAR, group, true) + toolbar.targetComponent = this + toolbar.updateActionsImmediately() + return toolbar.component + } + + internal fun addPath() { + val path = choose(parent)?.trim()?.takeIf { it.isNotBlank() } ?: return + if (path in cfg.paths) return + parent.updateSources(cfg.paths + path, cfg.urls) + } + + internal fun addUrl() { + val url = input( + KiloBundle.message("settings.agentBehavior.skills.sources.addUrl.title"), + KiloBundle.message("settings.agentBehavior.skills.sources.addUrl.prompt"), + )?.trim()?.takeIf { it.isNotBlank() } ?: return + if (url in cfg.urls) return + parent.updateSources(cfg.paths, cfg.urls + url) + } + + private fun rows(config: SkillsConfigDto): List { + val paths = config.paths.map { source(PATH_PREFIX, it, KiloBundle.message("settings.agentBehavior.skills.sources.paths")) } + val urls = config.urls.map { source(URL_PREFIX, it, KiloBundle.message("settings.agentBehavior.skills.sources.urls")) } + return paths + urls + } + + private fun source(prefix: String, value: String, section: String) = object : SettingsListItem { + override val key = prefix + value + override val title = value + override val section = section + override val cells = listOf(SettingsListCell( + DELETE_CELL, + KiloBundle.message("common.delete"), + icon = AllIcons.Actions.GC, + iconOnly = true, + )) + } + + private fun remove(key: String) { + when { + key.startsWith(PATH_PREFIX) -> parent.updateSources(cfg.paths - key.removePrefix(PATH_PREFIX), cfg.urls) + key.startsWith(URL_PREFIX) -> parent.updateSources(cfg.paths, cfg.urls - key.removePrefix(URL_PREFIX)) + } + } + + private inner class AddPathAction : DumbAwareAction( + KiloBundle.message("settings.agentBehavior.skills.sources.addPath"), + null, + AllIcons.General.Add, + ) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + override fun actionPerformed(e: AnActionEvent) = addPath() + } + + private inner class AddUrlAction : DumbAwareAction( + KiloBundle.message("settings.agentBehavior.skills.sources.addUrl"), + null, + AllIcons.General.Add, + ) { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + override fun actionPerformed(e: AnActionEvent) = addUrl() + } + + private companion object { + const val DELETE_CELL = "delete" + const val PATH_PREFIX = "path:" + const val URL_PREFIX = "url:" + } +} + +private fun chooseSkillPath(parent: JComponent): String? { + val descriptor = FileChooserDescriptor(false, true, false, false, false, false).apply { + title = KiloBundle.message("settings.agentBehavior.skills.sources.addPath.title") + description = KiloBundle.message("settings.agentBehavior.skills.sources.addPath.prompt") + } + return FileChooser.chooseFile(descriptor, parent, null, null as VirtualFile?)?.path +} + +private fun inputSkillUrl(title: String, prompt: String): String? = Messages.showInputDialog( + prompt, + title, + Messages.getQuestionIcon(), +) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt index 81cac63adc..eac5325e2d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt @@ -21,6 +21,7 @@ internal data class SettingsListConfig( val height: SettingsListRowHeight, val description: Boolean = true, val descriptionIndent: Boolean = true, + val tooltip: Boolean = true, ) { companion object { val Equal = SettingsListConfig(SettingsListRowHeight.EQUAL) @@ -41,6 +42,7 @@ internal data class SettingsListCell( internal interface SettingsListItem { val key: String val title: String + val note: String? get() = null val description: String? get() = null val icon: Icon? get() = null val section: String? get() = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt index 314e2c64b7..074ea866be 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt @@ -83,6 +83,9 @@ internal class SettingsListRenderer( title.clear() title.append(value.title, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg)) + value.note?.takeIf { it.isNotBlank() }?.let { + title.append(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) + } syncBadges(value) icon.icon = value.icon mark.isVisible = value.icon != null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt index 661ac90103..73eebcda40 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt @@ -24,7 +24,7 @@ internal class SettingsListView( private val model = CollectionListModel() internal val list = object : JBList(model) { override fun getToolTipText(event: MouseEvent): String? { - if (!cfg.description) return null + if (!cfg.description || !cfg.tooltip) return null val idx = locationToIndex(event.point) if (idx < 0) return null val bounds = getCellBounds(idx, idx) ?: return null diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index d08df1f9f9..02630c6f17 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -76,6 +76,13 @@ bundle="messages.KiloBundle" key="settings.agentBehavior.mcp.displayName"/> + + edt { panel.dispose(); true } } + ui = null + scope?.cancel() + scope = null + } finally { + super.tearDown() + } + } + + fun `test loads skills with location note and builtins have no actions`() { + val panel = panel() + + flushUntil { rows(panel).size == 2 } + + edt { + val rows = rows(panel) + val custom = rows.single { it.key == CUSTOM } + assertEquals("plan", custom.title) + assertEquals(CUSTOM, custom.note) + assertEquals("Plan work", custom.description) + assertEquals(listOf("open", "delete"), custom.cells.map { it.id }) + val open = custom.cells.single { it.id == "open" } + assertEquals(KiloBundle.message("settings.agentBehavior.skills.open"), open.label) + assertTrue(open.primary) + assertFalse(open.iconOnly) + assertNull(open.icon) + assertTrue(custom.cells.single { it.id == "delete" }.iconOnly) + val builtin = rows.single { it.key == "builtin" } + assertEquals("thinking", builtin.title) + assertNull(builtin.note) + assertEquals(listOf("built-in"), builtin.badges.map { it.text }) + assertTrue(builtin.cells.isEmpty()) + assertEquals(listOf(DIR), agentRpc.skillCalls) + true + } + } + + fun `test skills list does not show description tooltips`() { + val panel = panel() + flushUntil { rows(panel).size == 2 } + + edt { + val list = skillsList(panel) + list.size = Dimension(520, 320) + list.doLayout() + val bounds = list.getCellBounds(0, 0) + + assertNull(list.getToolTipText(mouse(list, MouseEvent.MOUSE_MOVED, Point(bounds.x + 8, bounds.y + 8)))) + true + } + } + + fun `test renderer puts location on first line and description on preview line`() { + val panel = panel() + flushUntil { rows(panel).size == 2 } + + edt { + val list = skillsList(panel) + val row = rows(panel).single { it.key == CUSTOM } + val idx = rows(panel).indexOf(row) + val comp = list.cellRenderer.getListCellRendererComponent(list, row, idx, true, true) + comp.setSize(520, list.fixedCellHeight) + layout(comp) + val title = components(comp).filterIsInstance().single() + val labels = components(comp).filterIsInstance().filter { it.isVisible }.map { it.text } + + assertEquals("plan $CUSTOM", title.toString()) + assertTrue(labels.contains("Plan work")) + true + } + } + + fun `test open action calls direct open file`() { + val panel = panel() + flushUntil { rows(panel).size == 2 } + + click(skillsList(panel), panel, CUSTOM, "open") + + flushUntil { workspaceRpc.opened.contains(CUSTOM) } + assertEquals(listOf(CUSTOM), workspaceRpc.opened) + assertTrue(workspaceRpc.fileCalls.isEmpty()) + } + + fun `test delete action removes skill and reloads`() { + val panel = panel() + flushUntil { rows(panel).size == 2 } + TestDialogManager.setTestDialog(TestDialog.YES) + + click(skillsList(panel), panel, CUSTOM, "delete") + + flushUntil { rows(panel).none { it.key == CUSTOM } } + assertEquals(listOf(DIR to CUSTOM), agentRpc.skillRemovals) + } + + fun `test delete action requires confirmation`() { + val panel = panel() + flushUntil { rows(panel).size == 2 } + TestDialogManager.setTestDialog { Messages.NO } + + click(skillsList(panel), panel, CUSTOM, "delete") + + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(agentRpc.skillRemovals.isEmpty()) + assertTrue(edt { rows(panel).any { it.key == CUSTOM } }) + } + + fun `test add path and url write skills config patch`() { + var path = "/extra/skills" + var url = "https://skills.test/index.json" + val panel = panel(choose = { path }, input = { _, _ -> url }) + flushUntil { rows(panel).size == 2 } + + edt { panel.sources.addPath(); true } + flushUntil { appRpc.configPatches.size == 1 } + flushUntil { edt { skillsList(panel).isEnabled } } + edt { panel.sources.addUrl(); true } + flushUntil { appRpc.configPatches.size == 2 } + + val paths = appRpc.configPatches.first().skills!!.paths + val urls = appRpc.configPatches.last().skills!!.urls + assertEquals(listOf("/global/skills", path), paths) + assertEquals(listOf("https://skills.test/base.json", url), urls) + } + + fun `test delete source writes skills config patch`() { + val panel = panel() + flushUntil { rows(panel).size == 2 && sourceRows(panel).size == 2 } + + click(sourceList(panel), panel, "path:/global/skills", "delete") + + flushUntil { appRpc.configPatches.size == 1 } + val patch = appRpc.configPatches.single().skills!! + assertEquals(emptyList(), patch.paths) + assertEquals(listOf("https://skills.test/base.json"), patch.urls) + } + + fun `test search filters skills by name`() { + val panel = panel() + flushUntil { rows(panel).size == 2 } + + edt { + components(panel).filterIsInstance().single().text = "think" + UIUtil.dispatchAllInvocationEvents() + true + } + + flushUntil { rows(panel).map { it.key } == listOf("builtin") } + } + + private fun panel( + choose: (JComponent) -> String? = { null }, + input: (String, String) -> String? = { _, _ -> null }, + ): SkillsSettingsUi { + install() + val panel = edt { SkillsSettingsUi(scope!!, DIR, choose, input) } + ui = panel + edt { panel.reload(); true } + return panel + } + + private fun install() { + val cs = CoroutineScope(SupervisorJob()) + scope = cs + appRpc = FakeAppRpcApi() + agentRpc = FakeAgentBehaviorRpcApi().apply { + skills = listOf( + SkillDto("plan", "Plan work", CUSTOM), + SkillDto("thinking", "Built in", "builtin"), + ) + } + workspaceRpc = FakeWorkspaceRpcApi() + app = KiloAppService(cs, appRpc) + val ready = KiloAppStateDto( + KiloAppStatusDto.READY, + config = ConfigDto(skills = SkillsConfigDto( + paths = listOf("/global/skills"), + urls = listOf("https://skills.test/base.json"), + )), + ) + app._state.value = ready + appRpc.state.value = ready + ApplicationManager.getApplication().replaceService(KiloAppService::class.java, app, testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloAgentBehaviorService::class.java, KiloAgentBehaviorService(cs, agentRpc), testRootDisposable) + ApplicationManager.getApplication().replaceService(KiloWorkspaceService::class.java, KiloWorkspaceService(cs, workspaceRpc), testRootDisposable) + } + + private fun click(list: JBList, panel: SkillsSettingsUi, key: String, id: String) { + edt { + list.size = Dimension(520, 320) + list.doLayout() + val rows = if (list === skillsList(panel)) rows(panel) else sourceRows(panel) + val idx = rows.indexOfFirst { it.key == key } + list.selectedIndex = idx + val area = settingsListCellBounds(list, idx, selected = true).getValue(id) + click(list, center(area)) + true + } + } + + private fun rows(panel: SkillsSettingsUi): List = items(skillsList(panel)) + + private fun sourceRows(panel: SkillsSettingsUi): List = items(sourceList(panel)) + + private fun items(list: JBList): List { + val model = list.model + return (0 until model.size).map { model.getElementAt(it) } + } + + private fun skillsList(panel: SkillsSettingsUi) = components(panel).filterIsInstance>().first() + + private fun sourceList(panel: SkillsSettingsUi) = components(panel).filterIsInstance>().last() + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + fun visit(item: java.awt.Component) { + out += item + if (item is Container) item.components.forEach { visit(it) } + } + visit(root) + return out + } + + private fun layout(root: java.awt.Component) { + root.doLayout() + if (root is Container) root.components.filterIsInstance().forEach { layout(it) } + UIUtil.dispatchAllInvocationEvents() + } + + private fun center(rect: java.awt.Rectangle) = Point(rect.x + rect.width / 2, rect.y + rect.height / 2) + + private fun click(list: JBList, point: Point) { + fire(list, mouse(list, MouseEvent.MOUSE_PRESSED, point)) + fire(list, mouse(list, MouseEvent.MOUSE_RELEASED, point)) + } + + private fun mouse(list: JBList, id: Int, point: Point) = MouseEvent( + list, + id, + System.currentTimeMillis(), + if (id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1, + ) + + private fun edt(block: () -> T): T { + var result: T? = null + ApplicationManager.getApplication().invokeAndWait { result = block() } + @Suppress("UNCHECKED_CAST") + return result as T + } + + private fun flushUntil(done: () -> Boolean) = runBlocking { + repeat(300) { + delay(10) + edt { UIUtil.dispatchAllInvocationEvents(); true } + if (done()) return@runBlocking + } + edt { UIUtil.dispatchAllInvocationEvents(); true } + assertTrue(done()) + } + + private companion object { + const val DIR = "/test" + const val CUSTOM = "/home/test/.config/kilo/skill/plan/SKILL.md" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt index bbcc487317..bb11472b06 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAgentBehaviorRpcApi.kt @@ -11,9 +11,12 @@ import ai.kilocode.rpc.dto.SkillDto class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var agents = emptyList() + var skills = emptyList() var mcps = emptyList() var mcpConfigs = emptyMap() val agentCalls = mutableListOf() + val skillCalls = mutableListOf() + val skillRemovals = mutableListOf>() val mcpCalls = mutableListOf() val mcpConfigCalls = mutableListOf() val mcpSaves = mutableListOf>() @@ -28,9 +31,11 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { var afterMcpConnect: (suspend (String, String) -> Unit)? = null var createError: Exception? = null var removeError: Exception? = null + var removeSkillError: Exception? = null var mcpStatusError: Exception? = null var mcpConnectError: Exception? = null var removeResult = true + var removeSkillResult = true var mcpConnectResult = true var mcpDisconnectResult = true var mcpAuthenticateResult = true @@ -43,12 +48,16 @@ class FakeAgentBehaviorRpcApi : KiloAgentBehaviorRpcApi { override suspend fun skills(directory: String): List { assertNotEdt("agentBehavior.skills") - return emptyList() + skillCalls.add(directory) + return skills } override suspend fun removeSkill(directory: String, location: String): Boolean { assertNotEdt("agentBehavior.removeSkill") - return false + removeSkillError?.let { throw it } + skillRemovals.add(directory to location) + if (removeSkillResult) skills = skills.filterNot { it.location == location } + return removeSkillResult } override suspend fun removeAgent(directory: String, name: String): Boolean { From 6c03a5c0766823cef8e59e5f0363dc1d9e0bf2ea Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:24:19 +0000 Subject: [PATCH 08/33] fix: allow ES2023 library APIs Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-vscode/tsconfig.json | 2 +- packages/kilo-vscode/webview-ui/tsconfig.json | 2 +- packages/plugin-atomic-chat/tsconfig.json | 2 +- packages/plugin/tsconfig.json | 2 +- packages/sdk/js/tsconfig.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/tsconfig.json b/packages/kilo-vscode/tsconfig.json index 868845e9f1..b8012f1644 100644 --- a/packages/kilo-vscode/tsconfig.json +++ b/packages/kilo-vscode/tsconfig.json @@ -3,7 +3,7 @@ "module": "ESNext", "moduleResolution": "bundler", "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "sourceMap": true, "rootDir": "src", "strict": true /* enable all strict type-checking options */, diff --git a/packages/kilo-vscode/webview-ui/tsconfig.json b/packages/kilo-vscode/webview-ui/tsconfig.json index e3e1ededb8..799a9cc7fe 100644 --- a/packages/kilo-vscode/webview-ui/tsconfig.json +++ b/packages/kilo-vscode/webview-ui/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "preserve", "jsxImportSource": "solid-js", "strict": true, diff --git a/packages/plugin-atomic-chat/tsconfig.json b/packages/plugin-atomic-chat/tsconfig.json index 7c3ad88a0b..4ef4f69a31 100644 --- a/packages/plugin-atomic-chat/tsconfig.json +++ b/packages/plugin-atomic-chat/tsconfig.json @@ -7,7 +7,7 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "lib": ["es2022", "dom", "dom.iterable"], + "lib": ["es2023", "dom", "dom.iterable"], "types": ["node"] }, "include": ["src/**/*", "test/**/*"] diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index ff001fd537..36925019e0 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -7,7 +7,7 @@ "module": "nodenext", "declaration": true, "moduleResolution": "nodenext", - "lib": ["es2022", "dom", "dom.iterable"], + "lib": ["es2023", "dom", "dom.iterable"], "types": ["node"] // kilocode_change }, "include": ["src"] diff --git a/packages/sdk/js/tsconfig.json b/packages/sdk/js/tsconfig.json index 54e6b62d0b..df6faf2d8a 100644 --- a/packages/sdk/js/tsconfig.json +++ b/packages/sdk/js/tsconfig.json @@ -6,7 +6,7 @@ "module": "nodenext", "declaration": true, "moduleResolution": "nodenext", - "lib": ["es2022", "dom", "dom.iterable"], + "lib": ["es2023", "dom", "dom.iterable"], "types": ["node"], "composite": true, "rootDir": "src" From 0602394d62837ba9b585269e58963d75a085ea6c Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Jul 2026 15:09:57 -0400 Subject: [PATCH 09/33] fix(jetbrains): polish context settings UI --- .../settings/KiloSettingsConfigurable.kt | 16 +-- .../settings/context/ContextSettingsUi.kt | 132 +++++++++++------- .../resources/kilo.jetbrains.frontend.xml | 16 +-- .../resources/messages/KiloBundle.properties | 25 ++-- .../messages/KiloBundle_ar.properties | 22 +++ .../messages/KiloBundle_bs.properties | 22 +++ .../messages/KiloBundle_da.properties | 22 +++ .../messages/KiloBundle_de.properties | 22 +++ .../messages/KiloBundle_es.properties | 22 +++ .../messages/KiloBundle_fr.properties | 22 +++ .../messages/KiloBundle_ja.properties | 22 +++ .../messages/KiloBundle_ko.properties | 22 +++ .../messages/KiloBundle_nl.properties | 22 +++ .../messages/KiloBundle_no.properties | 22 +++ .../messages/KiloBundle_pl.properties | 22 +++ .../messages/KiloBundle_pt_BR.properties | 22 +++ .../messages/KiloBundle_ru.properties | 22 +++ .../messages/KiloBundle_th.properties | 22 +++ .../messages/KiloBundle_tr.properties | 22 +++ .../messages/KiloBundle_uk.properties | 22 +++ .../messages/KiloBundle_zh_CN.properties | 22 +++ .../messages/KiloBundle_zh_TW.properties | 22 +++ .../settings/KiloSettingsConfigurableTest.kt | 2 +- .../settings/context/ContextSettingsUiTest.kt | 78 +++++++++-- 24 files changed, 576 insertions(+), 89 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt index 1f441f1602..e94db1b366 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/KiloSettingsConfigurable.kt @@ -58,14 +58,6 @@ class KiloSettingsConfigurable : SearchableConfigurable { models.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(models) - val context = ActionLink(KiloBundle.message("settings.context.displayName")) { e -> - val src = e.source as? JComponent ?: return@ActionLink - val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink - open(settings, ContextConfigurable.ID) - } - context.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) - panel.next(context) - val providers = ActionLink(KiloBundle.message("settings.providers.displayName")) { e -> val src = e.source as? JComponent ?: return@ActionLink val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink @@ -82,6 +74,14 @@ class KiloSettingsConfigurable : SearchableConfigurable { behavior.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) panel.next(behavior) + val context = ActionLink(KiloBundle.message("settings.context.displayName")) { e -> + val src = e.source as? JComponent ?: return@ActionLink + val settings = Settings.KEY.getData(DataManager.getInstance().getDataContext(src)) ?: return@ActionLink + open(settings, ContextConfigurable.ID) + } + context.border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) + panel.next(context) + return panel } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt index 570b860b6d..17af75815f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -7,8 +7,8 @@ import ai.kilocode.client.settings.base.BaseContentPanel import ai.kilocode.client.settings.base.BaseSettingsUi import ai.kilocode.client.settings.base.SettingsBannerKind import ai.kilocode.client.settings.base.SettingsRow -import ai.kilocode.client.settings.base.SettingsStackedRow import ai.kilocode.client.settings.base.SettingsToggle +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack @@ -22,16 +22,21 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.ModelStateDto import com.intellij.icons.AllIcons import com.intellij.openapi.components.service +import com.intellij.openapi.ui.Messages import com.intellij.ui.CollectionListModel import com.intellij.ui.DocumentAdapter -import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.ScrollingUtil import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextField import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope -import javax.swing.JButton +import java.awt.event.KeyEvent import javax.swing.JComponent import javax.swing.ListSelectionModel +import javax.swing.ScrollPaneConstants import javax.swing.event.DocumentEvent import javax.swing.text.AbstractDocument import javax.swing.text.AttributeSet @@ -125,13 +130,14 @@ internal class ContextSettingsContent( ) : BaseContentPanel() { private val auto = SettingsToggle { value -> update { copy(auto = value) } } private val prune = SettingsToggle { value -> update { copy(prune = value) } } - private val threshold = ThresholdField { value -> update { copy(threshold = value) } } + private val threshold = ThresholdField( + KiloBundle.message("settings.context.compaction.threshold.placeholder"), + ) { value -> update { copy(threshold = value) } } private val patterns = PatternList { value -> update { copy(ignore = value) } } init { section( KiloBundle.message("settings.context.compaction.title"), - KiloBundle.message("settings.context.compaction.description"), ).apply { row(SettingsRow( KiloBundle.message("settings.context.compaction.auto.title"), @@ -141,7 +147,10 @@ internal class ContextSettingsContent( row(SettingsRow( KiloBundle.message("settings.context.compaction.threshold.title"), KiloBundle.message("settings.context.compaction.threshold.description"), - threshold.align(HAlign.RIGHT, VAlign.CENTER), + Stack.horizontal(UiStyle.Gap.xs()) + .next(threshold) + .next(JBLabel("%")) + .align(HAlign.RIGHT, VAlign.CENTER), )) row(SettingsRow( KiloBundle.message("settings.context.compaction.prune.title"), @@ -152,11 +161,7 @@ internal class ContextSettingsContent( section( KiloBundle.message("settings.context.watcher.title"), KiloBundle.message("settings.context.watcher.description"), - ).row(SettingsStackedRow( - KiloBundle.message("settings.context.watcher.patterns.title"), - KiloBundle.message("settings.context.watcher.patterns.description"), - patterns, - )) + ).row(patterns) } @RequiresEdt @@ -170,13 +175,14 @@ internal class ContextSettingsContent( } private class ThresholdField( + placeholder: String, private val change: (String) -> Unit, ) : JBTextField() { private var syncing = false init { columns = THRESHOLD_COLUMNS - emptyText.text = KiloBundle.message("settings.context.compaction.threshold.placeholder") + emptyText.text = placeholder (document as AbstractDocument).documentFilter = NumberFilter() document.addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { @@ -208,7 +214,9 @@ private class NumberFilter : DocumentFilter() { private fun valid(value: String): Boolean { if (value.count { it == '.' } > 1) return false - return value.all { it.isDigit() || it == '.' } + if (!value.all { it.isDigit() || it == '.' }) return false + val num = value.toDoubleOrNull() ?: return false + return num >= 0.0 && num <= 100.0 } } @@ -216,76 +224,98 @@ internal class PatternList( private val change: (List) -> Unit, ) : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()) { private val model = CollectionListModel() - internal val entry = JBTextField().apply { - emptyText.text = KiloBundle.message("settings.context.watcher.placeholder") + internal var input: () -> String? = { + Messages.showInputDialog( + this, + KiloBundle.message("settings.context.watcher.input.prompt"), + KiloBundle.message("settings.context.watcher.input.title"), + null, + ) } - private val add = JButton(KiloBundle.message("settings.context.watcher.add"), AllIcons.General.Add).apply { + private val add = HoverIcon().apply { + icon = AllIcons.General.Add + toolTipText = KiloBundle.message("settings.context.watcher.add") addActionListener { add() } } + private val remove = HoverIcon().apply { + icon = AllIcons.General.Remove + toolTipText = KiloBundle.message("settings.context.watcher.remove") + addActionListener { remove() } + } private val list = JBList(model).apply { - selectionMode = ListSelectionModel.SINGLE_SELECTION + selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION + isFocusable = true emptyText.text = KiloBundle.message("settings.context.watcher.empty") } - private val panel = ToolbarDecorator.createDecorator(list) - .disableUpDownActions() - .disableAddAction() - .setRemoveAction { remove() } - .setRemoveActionUpdater { isEnabled && list.selectedIndex >= 0 } - .createPanel() + private val toolbar = Stack.horizontal().next(add).next(remove) + private val scroll = JBScrollPane(list).apply { + border = null + viewportBorder = null + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } init { - entry.document.addDocumentListener(object : DocumentAdapter() { - override fun textChanged(e: DocumentEvent) = syncAdd() - }) - entry.registerKeyboardAction( - { add() }, - javax.swing.KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ENTER, 0), + border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0) + list.addListSelectionListener { if (!it.valueIsAdjusting) syncActions() } + list.registerKeyboardAction( + { remove() }, + javax.swing.KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), JComponent.WHEN_FOCUSED, ) - next(Stack.horizontal(UiStyle.Gap.sm()).next(entry).next(add)) - next(panel) - syncAdd() + ScrollingUtil.installActions(list) + next(toolbar.align(HAlign.LEFT, VAlign.CENTER)) + gap(UiStyle.Gap.sm()) + next(scroll) + syncActions() } @RequiresEdt fun sync(values: List) { - if (model.items == values) return - model.replaceAll(values) - syncAdd() + if (model.items != values) model.replaceAll(values) + syncActions() } override fun setEnabled(enabled: Boolean) { super.setEnabled(enabled) - entry.isEnabled = enabled - add.isEnabled = enabled && entry.text.trim().isNotBlank() + add.isEnabled = enabled + remove.isEnabled = enabled && list.selectedIndices.isNotEmpty() list.isEnabled = enabled - panel.isEnabled = enabled - syncAdd() + scroll.isEnabled = enabled + toolbar.isEnabled = enabled + syncActions() } private fun add() { - val value = entry.text.trim() + if (!isEnabled) return + val value = input()?.trim().orEmpty() if (!isEnabled || value.isBlank()) return val values = model.items.toMutableList() - if (value !in values) values += value - entry.text = "" - model.replaceAll(values) - change(values) - syncAdd() + val idx = values.indexOf(value).takeIf { it >= 0 } ?: run { + values += value + model.replaceAll(values) + change(values) + values.lastIndex + } + list.selectedIndex = idx + ScrollingUtil.ensureIndexIsVisible(list, idx, 0) + syncActions() } private fun remove() { - val idx = list.selectedIndex - if (!isEnabled || idx < 0 || idx >= model.size) return + val indices = list.selectedIndices.filter { it >= 0 && it < model.size } + if (!isEnabled || indices.isEmpty()) return val values = model.items.toMutableList() - values.removeAt(idx) + indices.sortedDescending().forEach(values::removeAt) model.replaceAll(values) + val next = indices.minOrNull()?.coerceAtMost(values.lastIndex) ?: -1 + if (next >= 0) list.selectedIndex = next else list.clearSelection() change(values) - syncAdd() + syncActions() } - private fun syncAdd() { - add.isEnabled = isEnabled && entry.text.trim().isNotBlank() + private fun syncActions() { + add.isEnabled = isEnabled + remove.isEnabled = isEnabled && list.selectedIndices.isNotEmpty() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 2d65979af4..a498f01ab4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -46,14 +46,6 @@ bundle="messages.KiloBundle" key="settings.models.displayName"/> - - + + ().single() - list.entry.text = "**/dist/**" - buttons(panel).single { it.text == "Add pattern" }.doClick() + val patterns = components(panel).filterIsInstance().single() + patterns.input = { "**/dist/**" } + icon(panel, "Add pattern").doClick() + assertEquals(listOf("**/dist/**"), patternList(panel).selectedValuesList) panel.applyDraft() } @@ -121,6 +146,36 @@ class ContextSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf("tmp/**", "**/dist/**"), rpc.configPatches.single().watcher?.ignore) } + fun `test removing selected watcher patterns supports multi selection`() { + val panel = requireUi() + + edt { + val patterns = components(panel).filterIsInstance().single() + val inputs = ArrayDeque(listOf("**/dist/**", "**/build/**")) + patterns.input = { inputs.removeFirst() } + icon(panel, "Add pattern").doClick() + icon(panel, "Add pattern").doClick() + val list = patternList(panel) + assertEquals(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, list.selectionMode) + list.setSelectionInterval(0, 1) + icon(panel, "Remove selected patterns").doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("**/build/**"), rpc.configPatches.single().watcher?.ignore) + } + + fun `test watcher section does not repeat ignored patterns row title`() { + val panel = requireUi() + + edt { + assertFalse(text(panel).contains("Ignored patterns")) + assertTrue(text(panel).contains("File Watcher Ignore Patterns")) + assertEquals(1, components(panel).filterIsInstance().size) + } + } + fun `test failed apply stays visible while panel open`() { val panel = requireUi() rpc.configUpdateError = RuntimeException("save failed") @@ -154,11 +209,19 @@ class ContextSettingsUiTest : BasePlatformTestCase() { private fun requireUi(): ContextSettingsUi = requireNotNull(ui) - private fun threshold(panel: ContextSettingsUi): JTextField = components(panel) - .filterIsInstance() + private fun threshold(panel: ContextSettingsUi): JBTextField = components(panel) + .filterIsInstance() .single { it.columns == 8 } - private fun buttons(panel: ContextSettingsUi): List = components(panel).filterIsInstance() + private fun patternList(panel: ContextSettingsUi): JBList { + val list = components(panel).filterIsInstance>().single() + @Suppress("UNCHECKED_CAST") + return list as JBList + } + + private fun icon(panel: ContextSettingsUi, tip: String): HoverIcon = components(panel) + .filterIsInstance() + .single { it.toolTipText == tip } private fun edt(block: () -> T): T { var result: T? = null @@ -185,7 +248,6 @@ class ContextSettingsUiTest : BasePlatformTestCase() { is AbstractButton -> comp.text?.let { out.add(it) } is JLabel -> comp.text?.let { out.add(it) } is JTextComponent -> comp.text?.let { out.add(it) } - is JTextField -> comp.text?.let { out.add(it) } } } return out.joinToString("\n") From bd84110667dc36803417b82ac87b441cac2dafd5 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Jul 2026 15:59:35 -0400 Subject: [PATCH 10/33] fix(jetbrains): address context settings review --- .../kilocode/backend/cli/KiloCliDataParser.kt | 6 +++--- .../backend/cli/KiloCliDataParserTest.kt | 17 ++++++++++++++++ .../settings/context/ContextSettingsState.kt | 7 +++---- .../settings/context/ContextSettingsUi.kt | 6 +++--- .../resources/messages/KiloBundle.properties | 1 + .../messages/KiloBundle_ar.properties | 1 + .../messages/KiloBundle_bs.properties | 1 + .../messages/KiloBundle_da.properties | 1 + .../messages/KiloBundle_de.properties | 1 + .../messages/KiloBundle_es.properties | 1 + .../messages/KiloBundle_fr.properties | 1 + .../messages/KiloBundle_ja.properties | 1 + .../messages/KiloBundle_ko.properties | 1 + .../messages/KiloBundle_nl.properties | 1 + .../messages/KiloBundle_no.properties | 1 + .../messages/KiloBundle_pl.properties | 1 + .../messages/KiloBundle_pt_BR.properties | 1 + .../messages/KiloBundle_ru.properties | 1 + .../messages/KiloBundle_th.properties | 1 + .../messages/KiloBundle_tr.properties | 1 + .../messages/KiloBundle_uk.properties | 1 + .../messages/KiloBundle_zh_CN.properties | 1 + .../messages/KiloBundle_zh_TW.properties | 1 + .../context/ContextSettingsStateTest.kt | 20 +++++++++---------- 24 files changed, 55 insertions(+), 20 deletions(-) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index af023767ae..a3f265ad3d 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -546,9 +546,9 @@ object KiloCliDataParser { private fun parseCompactionConfig(obj: JsonObject?): CompactionConfigDto? { if (obj == null) return null return CompactionConfigDto( - auto = obj.flagOrNull("auto"), - threshold_percent = obj.num("threshold_percent"), - prune = obj.flagOrNull("prune"), + auto = runCatching { obj.flagOrNull("auto") }.getOrNull(), + threshold_percent = runCatching { obj.num("threshold_percent") }.getOrNull(), + prune = runCatching { obj.flagOrNull("prune") }.getOrNull(), ) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index de244ad0f8..59b6467140 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -1190,6 +1190,23 @@ class KiloCliDataParserTest { assertEquals(false, cfg.compaction?.prune) } + @Test + fun `parseConfig - malformed compaction fields do not discard config`() { + val cfg = KiloCliDataParser.parseConfig( + """{ + "model":"openai/gpt", + "watcher":{"ignore":["tmp/**"]}, + "compaction":{"auto":{},"threshold_percent":[],"prune":false} + }""" + ) + + assertEquals("openai/gpt", cfg.model) + assertEquals(listOf("tmp/**"), cfg.watcher?.ignore) + assertNull(cfg.compaction?.auto) + assertNull(cfg.compaction?.threshold_percent) + assertEquals(false, cfg.compaction?.prune) + } + @Test fun `parseConfig - agent overrides and permissions`() { val cfg = KiloCliDataParser.parseConfig( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt index d703e75be1..3a5989a332 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt @@ -24,8 +24,8 @@ internal fun contextDraft(config: ConfigDto?): ContextDraft = ContextDraft( ignore = config?.watcher?.ignore ?: emptyList(), ) -internal fun patch(from: ContextDraft, to: ContextDraft): ConfigPatchDto { - if (thresholdStatus(to.threshold) == ThresholdStatus.INVALID) return ConfigPatchDto() +internal fun patch(from: ContextDraft, to: ContextDraft): ConfigPatchDto? { + if (thresholdStatus(to.threshold) == ThresholdStatus.INVALID) return null val compaction = compactionPatch(from, to) val watcher = if (from.ignore != to.ignore) WatcherPatchDto(ignore = to.ignore) else null @@ -49,10 +49,9 @@ internal fun thresholdStatus(value: String): ThresholdStatus { } private fun compactionPatch(from: ContextDraft, to: ContextDraft): CompactionPatchDto? { - val clear = mutableListOf() val threshold = parseThreshold(to.threshold) val fromThreshold = parseThreshold(from.threshold) - if (fromThreshold != threshold && threshold == null) clear += "threshold_percent" + val clear = if (fromThreshold != threshold && threshold == null) listOf("threshold_percent") else emptyList() val patch = CompactionPatchDto( clear = clear, auto = to.auto.takeIf { from.auto != to.auto }, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt index 17af75815f..d1fb89992f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -57,7 +57,7 @@ internal class ContextSettingsUi( startSettings(ContextSettingsContent { updateDraft(it) }) } - override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to).takeIf(::changed) + override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to)?.takeIf(::changed) override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { app.updateConfigAsync(change, done) @@ -149,7 +149,7 @@ internal class ContextSettingsContent( KiloBundle.message("settings.context.compaction.threshold.description"), Stack.horizontal(UiStyle.Gap.xs()) .next(threshold) - .next(JBLabel("%")) + .next(JBLabel(KiloBundle.message("settings.context.compaction.threshold.suffix"))) .align(HAlign.RIGHT, VAlign.CENTER), )) row(SettingsRow( @@ -288,7 +288,7 @@ internal class PatternList( private fun add() { if (!isEnabled) return val value = input()?.trim().orEmpty() - if (!isEnabled || value.isBlank()) return + if (value.isBlank()) return val values = model.items.toMutableList() val idx = values.indexOf(value).takeIf { it >= 0 } ?: run { values += value 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 8a8bd0fe33..9ee25b97e6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -313,6 +313,7 @@ settings.context.compaction.auto.description=Automatically compact context befor settings.context.compaction.threshold.title=Auto Compaction Limit settings.context.compaction.threshold.description=Compact when context reaches this percentage of the model window. Leave blank to use the safety buffer only. settings.context.compaction.threshold.placeholder=Default +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Enter a number from 0 to 100, or leave the field blank. settings.context.compaction.prune.title=Prune Old Outputs settings.context.compaction.prune.description=Remove old tool outputs during compaction diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 05c069c9f2..30e2d7bd0e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=ضغط السياق تلقائيا settings.context.compaction.threshold.title=حد الضغط التلقائي settings.context.compaction.threshold.description=اضغط عندما يصل السياق إلى هذه النسبة المئوية من نافذة النموذج. اتركه فارغاً لاستخدام هامش الأمان فقط. settings.context.compaction.threshold.placeholder=افتراضي +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=أدخل رقماً من 0 إلى 100، أو اترك الحقل فارغاً. settings.context.compaction.prune.title=تقليم المخرجات القديمة settings.context.compaction.prune.description=إزالة مخرجات الأدوات القديمة أثناء الضغط diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index d06eca9a5b..85685d47df 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Automatski komprimiraj kontekst pri settings.context.compaction.threshold.title=Limit automatske kompresije settings.context.compaction.threshold.description=Komprimiraj kada kontekst dostigne ovaj procenat prozora modela. Ostavite prazno da koristite samo sigurnosnu rezervu. settings.context.compaction.threshold.placeholder=Zadano +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Unesite broj od 0 do 100 ili ostavite polje prazno. settings.context.compaction.prune.title=Očisti stare izlaze settings.context.compaction.prune.description=Ukloni stare izlaze alata tokom kompresije diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 9cbb70a724..00f9ccc62c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Komprimér automatisk kontekst, fø settings.context.compaction.threshold.title=Grænse for automatisk komprimering settings.context.compaction.threshold.description=Komprimér, når konteksten når denne procentdel af modelvinduet. Lad feltet være tomt for kun at bruge sikkerhedsbufferen. settings.context.compaction.threshold.placeholder=Standard +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Indtast et tal fra 0 til 100, eller lad feltet være tomt. settings.context.compaction.prune.title=Fjern gamle output settings.context.compaction.prune.description=Fjern gamle værktøjsoutput under komprimering diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 76e9cc06cd..3f0d1cec7a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Kontext automatisch komprimieren, b settings.context.compaction.threshold.title=Limit für automatische Komprimierung settings.context.compaction.threshold.description=Komprimieren, wenn der Kontext diesen Prozentsatz des Modellfensters erreicht. Leer lassen, um nur den Sicherheitspuffer zu verwenden. settings.context.compaction.threshold.placeholder=Standard +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Geben Sie eine Zahl von 0 bis 100 ein oder lassen Sie das Feld leer. settings.context.compaction.prune.title=Alte Ausgaben bereinigen settings.context.compaction.prune.description=Alte Werkzeugausgaben während der Komprimierung entfernen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index c5e0aed2a8..071f529430 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Compactar automáticamente el conte settings.context.compaction.threshold.title=Límite de compactación automática settings.context.compaction.threshold.description=Compactar cuando el contexto alcance este porcentaje de la ventana del modelo. Déjalo en blanco para usar solo el búfer de seguridad. settings.context.compaction.threshold.placeholder=Predeterminado +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Introduce un número de 0 a 100 o deja el campo en blanco. settings.context.compaction.prune.title=Eliminar salidas antiguas settings.context.compaction.prune.description=Eliminar salidas de herramientas antiguas durante la compactación diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 10c08ff1ee..508507d278 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Compacter automatiquement le contex settings.context.compaction.threshold.title=Limite de compactage automatique settings.context.compaction.threshold.description=Compacter lorsque le contexte atteint ce pourcentage de la fenêtre du modèle. Laissez vide pour utiliser uniquement la marge de sécurité. settings.context.compaction.threshold.placeholder=Par défaut +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Saisissez un nombre de 0 à 100, ou laissez le champ vide. settings.context.compaction.prune.title=Élaguer les anciennes sorties settings.context.compaction.prune.description=Supprimer les anciennes sorties d’outils pendant la compaction diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 8ea7bc8568..6f6424b4b4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=コンテキストが上限に達 settings.context.compaction.threshold.title=自動圧縮の上限 settings.context.compaction.threshold.description=コンテキストがモデルウィンドウのこの割合に達したら圧縮します。安全バッファーのみを使用するには空欄のままにしてください。 settings.context.compaction.threshold.placeholder=デフォルト +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=0から100までの数値を入力するか、空欄のままにしてください。 settings.context.compaction.prune.title=古い出力を削除 settings.context.compaction.prune.description=圧縮時に古いツール出力を削除 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index d3543c6d2e..9f33c300ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=컨텍스트가 한도에 도달하 settings.context.compaction.threshold.title=자동 압축 한도 settings.context.compaction.threshold.description=컨텍스트가 모델 창의 이 비율에 도달하면 압축합니다. 안전 버퍼만 사용하려면 비워 두세요. settings.context.compaction.threshold.placeholder=기본값 +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=0에서 100 사이의 숫자를 입력하거나 필드를 비워 두세요. settings.context.compaction.prune.title=이전 출력 정리 settings.context.compaction.prune.description=압축 중 이전 도구 출력 제거 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index dcd77c3091..0b981fee3b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Context automatisch compacteren voo settings.context.compaction.threshold.title=Limiet voor automatisch compacteren settings.context.compaction.threshold.description=Compacteer wanneer de context dit percentage van het modelvenster bereikt. Laat leeg om alleen de veiligheidsbuffer te gebruiken. settings.context.compaction.threshold.placeholder=Standaard +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Voer een getal van 0 tot 100 in, of laat het veld leeg. settings.context.compaction.prune.title=Oude Uitvoer Opschonen settings.context.compaction.prune.description=Verwijder oude tool uitvoer tijdens compactie diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index b72a87bc8a..946ebb04d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Komprimer automatisk kontekst før settings.context.compaction.threshold.title=Grense for automatisk komprimering settings.context.compaction.threshold.description=Komprimer når konteksten når denne prosentandelen av modellvinduet. La stå tomt for å bare bruke sikkerhetsbufferen. settings.context.compaction.threshold.placeholder=Standard +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Skriv inn et tall fra 0 til 100, eller la feltet stå tomt. settings.context.compaction.prune.title=Fjern gamle utdata settings.context.compaction.prune.description=Fjern gamle verktøyutdata under komprimering diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 2adf1ae773..9b4e6eed92 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Automatycznie kompaktuj kontekst, z settings.context.compaction.threshold.title=Limit automatycznego kompaktowania settings.context.compaction.threshold.description=Kompaktuj, gdy kontekst osiągnie ten procent okna modelu. Pozostaw puste, aby używać tylko bufora bezpieczeństwa. settings.context.compaction.threshold.placeholder=Domyślne +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Wpisz liczbę od 0 do 100 albo pozostaw pole puste. settings.context.compaction.prune.title=Przytnij stare wyjścia settings.context.compaction.prune.description=Usuń stare wyjścia narzędzi podczas kompakcji diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 37381a7056..0bc0247b97 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Compactar automaticamente o context settings.context.compaction.threshold.title=Limite de compactação automática settings.context.compaction.threshold.description=Compacte quando o contexto atingir esta porcentagem da janela do modelo. Deixe em branco para usar apenas a margem de segurança. settings.context.compaction.threshold.placeholder=Padrão +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Digite um número de 0 a 100 ou deixe o campo em branco. settings.context.compaction.prune.title=Remover saídas antigas settings.context.compaction.prune.description=Remover saídas antigas de ferramentas durante a compactação diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index 318f4b2306..f5993cf000 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Автоматически сжим settings.context.compaction.threshold.title=Лимит автоматического сжатия settings.context.compaction.threshold.description=Сжимать, когда контекст достигает этого процента окна модели. Оставьте пустым, чтобы использовать только буфер безопасности. settings.context.compaction.threshold.placeholder=По умолчанию +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Введите число от 0 до 100 или оставьте поле пустым. settings.context.compaction.prune.title=Очистить старые выходные данные settings.context.compaction.prune.description=Удалить старые выходные данные инструментов при сжатии diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 31a2c4c6b0..bf99b6c0b6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=บีบอัดบริบท settings.context.compaction.threshold.title=ขีดจำกัดการบีบอัดอัตโนมัติ settings.context.compaction.threshold.description=บีบอัดเมื่อบริบทถึงเปอร์เซ็นต์นี้ของหน้าต่างโมเดล เว้นว่างไว้เพื่อใช้เฉพาะบัฟเฟอร์ความปลอดภัย settings.context.compaction.threshold.placeholder=ค่าเริ่มต้น +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=ป้อนตัวเลขตั้งแต่ 0 ถึง 100 หรือเว้นช่องว่างไว้ settings.context.compaction.prune.title=ตัดผลลัพธ์เก่า settings.context.compaction.prune.description=ลบผลลัพธ์เครื่องมือเก่าระหว่างการบีบอัด diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 592d082354..c1d1d2a855 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Bağlam sınıra ulaşmadan önce o settings.context.compaction.threshold.title=Otomatik sıkıştırma sınırı settings.context.compaction.threshold.description=Bağlam model penceresinin bu yüzdesine ulaştığında sıkıştır. Yalnızca güvenlik tamponunu kullanmak için boş bırakın. settings.context.compaction.threshold.placeholder=Varsayılan +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=0 ile 100 arasında bir sayı girin veya alanı boş bırakın. settings.context.compaction.prune.title=Eski Çıktıları Temizle settings.context.compaction.prune.description=Sıkıştırma sırasında eski araç çıktılarını kaldır diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 013b9552a1..f54be76f63 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=Автоматично стиска settings.context.compaction.threshold.title=Ліміт автоматичного стискання settings.context.compaction.threshold.description=Стискати, коли контекст досягає цього відсотка вікна моделі. Залиште порожнім, щоб використовувати лише буфер безпеки. settings.context.compaction.threshold.placeholder=За замовчуванням +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Введіть число від 0 до 100 або залиште поле порожнім. settings.context.compaction.prune.title=Очищати старі виводи settings.context.compaction.prune.description=Видаляти старі виводи інструментів під час стиснення diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index dfc3a3159a..817042a84f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=在上下文达到限制前自动 settings.context.compaction.threshold.title=自动压缩限制 settings.context.compaction.threshold.description=当上下文达到模型窗口的此百分比时进行压缩。留空则仅使用安全缓冲区。 settings.context.compaction.threshold.placeholder=默认 +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=输入 0 到 100 之间的数字,或将字段留空。 settings.context.compaction.prune.title=修剪旧输出 settings.context.compaction.prune.description=压缩期间移除旧的工具输出 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 8565506658..ac6adcf29b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -388,6 +388,7 @@ settings.context.compaction.auto.description=在上下文達到限制前自動 settings.context.compaction.threshold.title=自動壓縮限制 settings.context.compaction.threshold.description=當上下文達到模型視窗的此百分比時進行壓縮。留空則僅使用安全緩衝區。 settings.context.compaction.threshold.placeholder=預設 +settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=輸入 0 到 100 之間的數字,或將欄位留空。 settings.context.compaction.prune.title=修剪舊輸出 settings.context.compaction.prune.description=壓縮期間移除舊的工具輸出 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt index e41571cd94..8fc6dd6179 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt @@ -27,7 +27,7 @@ class ContextSettingsStateTest { fun `unchanged draft emits no patch`() { val draft = ContextDraft(auto = true, threshold = "75", prune = false, ignore = listOf("tmp/**")) - assertFalse(changed(patch(draft, draft))) + assertEquals(false, patch(draft, draft)?.let(::changed)) } @Test @@ -36,8 +36,8 @@ class ContextSettingsStateTest { val to = ContextDraft(auto = false, prune = false) val patch = patch(from, to) - assertEquals(false, patch.compaction?.auto) - assertEquals(false, patch.compaction?.prune) + assertEquals(false, patch?.compaction?.auto) + assertEquals(false, patch?.compaction?.prune) } @Test @@ -46,9 +46,9 @@ class ContextSettingsStateTest { val set = ContextDraft(threshold = "80") val clear = ContextDraft(threshold = "") - assertEquals(80.0, patch(from, set).compaction?.threshold_percent) - assertEquals(listOf("threshold_percent"), patch(set, clear).compaction?.clear) - assertNull(patch(set, clear).compaction?.threshold_percent) + assertEquals(80.0, patch(from, set)?.compaction?.threshold_percent) + assertEquals(listOf("threshold_percent"), patch(set, clear)?.compaction?.clear) + assertNull(patch(set, clear)?.compaction?.threshold_percent) } @Test @@ -56,16 +56,16 @@ class ContextSettingsStateTest { val from = ContextDraft(ignore = listOf("**/dist/**")) val to = ContextDraft(ignore = emptyList()) - assertEquals(emptyList(), patch(from, to).watcher?.ignore) + assertEquals(emptyList(), patch(from, to)?.watcher?.ignore) } @Test - fun `invalid threshold prevents patch`() { + fun `invalid threshold prevents patch without looking like no changes`() { val from = ContextDraft(threshold = "50") - val to = ContextDraft(threshold = "101") + val to = ContextDraft(auto = true, threshold = "101", prune = true, ignore = listOf("tmp/**")) assertEquals(ThresholdStatus.INVALID, thresholdStatus(to.threshold)) - assertFalse(changed(patch(from, to))) + assertNull(patch(from, to)) } @Test From 8f37445bdeeaa661e628ccbed328c623ffe5dbd6 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Jul 2026 17:01:43 -0400 Subject: [PATCH 11/33] fix(jetbrains): improve context pattern editing --- .../settings/context/ContextSettingsUi.kt | 58 +++++++++++++++++++ .../settings/context/ContextSettingsUiTest.kt | 42 ++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt index d1fb89992f..7a66f2ac59 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -32,9 +32,14 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBTextField import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import java.awt.event.KeyEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent import javax.swing.JComponent +import javax.swing.DefaultListCellRenderer +import javax.swing.JList import javax.swing.ListSelectionModel import javax.swing.ScrollPaneConstants import javax.swing.event.DocumentEvent @@ -232,6 +237,16 @@ internal class PatternList( null, ) } + internal var editor: (String) -> String? = { value -> + Messages.showInputDialog( + this, + KiloBundle.message("settings.context.watcher.input.prompt"), + KiloBundle.message("settings.context.watcher.title"), + null, + value, + null, + ) + } private val add = HoverIcon().apply { icon = AllIcons.General.Add toolTipText = KiloBundle.message("settings.context.watcher.add") @@ -246,6 +261,7 @@ internal class PatternList( selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION isFocusable = true emptyText.text = KiloBundle.message("settings.context.watcher.empty") + cellRenderer = PatternRenderer() } private val toolbar = Stack.horizontal().next(add).next(remove) private val scroll = JBScrollPane(list).apply { @@ -257,6 +273,14 @@ internal class PatternList( init { border = JBUI.Borders.empty(UiStyle.Gap.pad(), 0, UiStyle.Gap.pad(), 0) list.addListSelectionListener { if (!it.valueIsAdjusting) syncActions() } + list.addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount != 2 || !UIUtil.isActionClick(e, MouseEvent.MOUSE_CLICKED, true)) return + val idx = list.locationToIndex(e.point) + if (idx < 0 || list.getCellBounds(idx, idx)?.contains(e.point) != true) return + edit(idx) + } + }) list.registerKeyboardAction( { remove() }, javax.swing.KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), @@ -301,6 +325,26 @@ internal class PatternList( syncActions() } + private fun edit(idx: Int) { + if (!isEnabled || idx < 0 || idx >= model.size) return + val value = editor(model.getElementAt(idx))?.trim().orEmpty() + if (value.isBlank()) return + val values = model.items.toMutableList() + val found = values.indexOf(value) + val next = if (found >= 0 && found != idx) { + values.removeAt(idx) + if (found > idx) found - 1 else found + } else { + values[idx] = value + idx + } + model.replaceAll(values) + change(values) + list.selectedIndex = next + ScrollingUtil.ensureIndexIsVisible(list, next, 0) + syncActions() + } + private fun remove() { val indices = list.selectedIndices.filter { it >= 0 && it < model.size } if (!isEnabled || indices.isEmpty()) return @@ -317,6 +361,20 @@ internal class PatternList( add.isEnabled = isEnabled remove.isEnabled = isEnabled && list.selectedIndices.isNotEmpty() } + + private class PatternRenderer : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>?, + value: Any?, + index: Int, + selected: Boolean, + focus: Boolean, + ): java.awt.Component { + val comp = super.getListCellRendererComponent(list, value, index, selected, focus) as JComponent + comp.border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()) + return comp + } + } } private fun summary(patch: ConfigPatchDto): String { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt index 90b3a60d7d..c2d73e62d4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt @@ -23,7 +23,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import java.awt.Container +import java.awt.event.MouseEvent import javax.swing.AbstractButton +import javax.swing.JComponent import javax.swing.JLabel import javax.swing.ListSelectionModel import javax.swing.JTextField @@ -166,6 +168,46 @@ class ContextSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf("**/build/**"), rpc.configPatches.single().watcher?.ignore) } + fun `test double clicking watcher pattern edits it`() { + val panel = requireUi() + + edt { + val patterns = components(panel).filterIsInstance().single() + patterns.editor = { "**/edited/**" } + val list = patternList(panel) + list.setSize(400, 100) + list.doLayout() + val bounds = list.getCellBounds(0, 0) + val event = MouseEvent( + list, + MouseEvent.MOUSE_CLICKED, + System.currentTimeMillis(), + 0, + bounds.x + 1, + bounds.y + 1, + 2, + false, + MouseEvent.BUTTON1, + ) + list.mouseListeners.forEach { it.mouseClicked(event) } + assertEquals(listOf("**/edited/**"), list.selectedValuesList) + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() } + assertEquals(listOf("**/edited/**"), rpc.configPatches.single().watcher?.ignore) + } + + fun `test watcher pattern renderer has left inset`() { + val panel = requireUi() + + edt { + val list = patternList(panel) + val comp = list.cellRenderer.getListCellRendererComponent(list, "tmp/**", 0, false, false) as JComponent + assertTrue(comp.insets.left > 0) + } + } + fun `test watcher section does not repeat ignored patterns row title`() { val panel = requireUi() From 63fefce174fe3ccad49c6733326227306d751bc0 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 17 Jul 2026 17:20:29 -0400 Subject: [PATCH 12/33] fix(jetbrains): keep applied settings visible --- .../settings/base/SettingsDraftState.kt | 13 +++- .../settings/base/SettingsDraftStateTest.kt | 60 +++++++++++++++++++ .../settings/context/ContextSettingsUiTest.kt | 19 ++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt index ca8424f65b..335df8d50f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsDraftState.kt @@ -11,6 +11,8 @@ internal class SettingsDraftState( private var base = initial private var pending: D? = null + private var stale: List = emptyList() + private var applied: D? = null private var save = false private var err: String? = null @@ -29,6 +31,10 @@ internal class SettingsDraftState( fun accept(next: D) { val target = pending if (target == null) { + val done = applied + if (done != null && saved(base, done) && stale.any { saved(next, it) }) return + stale = emptyList() + applied = null val prev = base val edit = draft base = next @@ -51,12 +57,15 @@ internal class SettingsDraftState( fun complete(token: SettingsDraftSave, returned: D) { val edit = draft - val next = if (saved(returned, token.target)) returned else token.target + val fresh = saved(returned, token.target) + val next = if (fresh) returned else token.target base = next draft = if (saved(edit, token.target)) next else edit pending = null save = false err = null + stale += token.previous + applied = token.target } fun fail(token: SettingsDraftSave, message: String) { @@ -66,6 +75,8 @@ internal class SettingsDraftState( pending = null save = false err = message + stale = emptyList() + applied = null } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt index 89efbeb3de..f9cb8f4b8c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsDraftStateTest.kt @@ -88,6 +88,66 @@ class SettingsDraftStateTest { assertFalse(state.modified()) } + @Test + fun `stale external base after fallback completion does not revert applied target`() { + val state = SettingsDraftState("old") + state.update { "new" } + val token = state.start()!! + state.complete(token, "old") + + state.accept("old") + + assertEquals("new", state.baseline) + assertEquals("new", state.draft) + assertFalse(state.modified()) + } + + @Test + fun `stale external base after fresh completion does not revert applied target`() { + val state = SettingsDraftState("old") + state.update { "new" } + val token = state.start()!! + state.complete(token, "new") + + state.accept("old") + + assertEquals("new", state.baseline) + assertEquals("new", state.draft) + assertFalse(state.modified()) + } + + @Test + fun `fresh external base after ignored stale update is accepted`() { + val state = SettingsDraftState("old") + state.update { "new" } + val token = state.start()!! + state.complete(token, "old") + state.accept("old") + + state.accept("other") + + assertEquals("other", state.baseline) + assertEquals("other", state.draft) + assertFalse(state.modified()) + } + + @Test + fun `older stale external base after multiple saves is ignored`() { + val state = SettingsDraftState("old") + state.update { "new" } + val first = state.start()!! + state.complete(first, "new") + state.update { "other" } + val second = state.start()!! + state.complete(second, "other") + + state.accept("old") + + assertEquals("other", state.baseline) + assertEquals("other", state.draft) + assertFalse(state.modified()) + } + @Test fun `failed save keeps draft dirty and restores previous base`() { val state = SettingsDraftState("old") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt index c2d73e62d4..80c5a2e1c4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt @@ -148,6 +148,25 @@ class ContextSettingsUiTest : BasePlatformTestCase() { assertEquals(listOf("tmp/**", "**/dist/**"), rpc.configPatches.single().watcher?.ignore) } + fun `test stale config update result keeps watcher pattern visible`() { + val panel = requireUi() + rpc.configUpdateReturnStale = true + + edt { + val patterns = components(panel).filterIsInstance().single() + patterns.input = { "**/dist/**" } + icon(panel, "Add pattern").doClick() + panel.applyDraft() + } + + flushUntil { rpc.configPatches.isNotEmpty() && !edt { panel.modified() } } + edt { + val list = patternList(panel) + assertEquals(listOf("**/dist/**"), list.selectedValuesList) + assertEquals(listOf("tmp/**", "**/dist/**"), (0 until list.model.size).map { list.model.getElementAt(it) }) + } + } + fun `test removing selected watcher patterns supports multi selection`() { val panel = requireUi() From 99227f67478b44b06a18935792bd655c774f174a Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 17 Jul 2026 20:53:51 -0400 Subject: [PATCH 13/33] fix(cli): avoid standalone TUI preload lookup --- .changeset/standalone-tui-preload.md | 5 ++++ packages/opencode/src/cli/cmd/tui.ts | 9 ++++++- packages/opencode/src/kilocode/cli/cmd/tui.ts | 4 +++ .../test/kilocode/cli/tui/thread.test.ts | 26 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/standalone-tui-preload.md create mode 100644 packages/opencode/src/kilocode/cli/cmd/tui.ts diff --git a/.changeset/standalone-tui-preload.md b/.changeset/standalone-tui-preload.md new file mode 100644 index 0000000000..32305503db --- /dev/null +++ b/.changeset/standalone-tui-preload.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Start the TUI from standalone CLI installations without requiring project-local OpenTUI dependencies. diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6bb8eaf10a..ce9fa7e351 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -15,6 +15,7 @@ import { importCloudSession, localSessionID, validateCloudFork } from "@/kilocod import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { writeHeapSnapshot } from "v8" import { KiloTuiThreadDaemon, type StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change +import { preload } from "@/kilocode/cli/cmd/tui" // kilocode_change import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { validateSession } from "../tui/validate-session" // kilocode_change start - correlate the TUI worker with its parent process @@ -203,6 +204,12 @@ export const TuiThreadCommand = cmd({ // chdir so the thread and worker share the same directory key. const next = resolveThreadDirectory(args.project) const file = await target() + // kilocode_change start + const preloads = preload( + typeof KILO_WORKER_PATH !== "undefined", + () => import.meta.resolve("@opentui/solid/preload"), + ) + // kilocode_change end try { process.chdir(next) } catch { @@ -223,7 +230,7 @@ export const TuiThreadCommand = cmd({ }) // kilocode_change end const worker = new Worker(file, { - preload: ["@opentui/solid/preload"], // kilocode_change - Bun workers do not inherit the parent preload + preload: preloads, // kilocode_change env, // kilocode_change }) worker.onerror = (e) => { diff --git a/packages/opencode/src/kilocode/cli/cmd/tui.ts b/packages/opencode/src/kilocode/cli/cmd/tui.ts new file mode 100644 index 0000000000..c806b6d872 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui.ts @@ -0,0 +1,4 @@ +export function preload(compiled: boolean, resolve: () => string) { + if (compiled) return [] + return [resolve()] +} diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index fd8c040e5c..20e4289415 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -7,6 +7,7 @@ import { resolveThreadDirectory, runEmbeddedRemoteExitBridge, } from "../../../../src/cli/cmd/tui" +import { preload } from "../../../../src/kilocode/cli/cmd/tui" import { KiloTuiThreadDaemon } from "../../../../src/kilocode/cli/cmd/tui/thread" import { DaemonClient } from "../../../../src/kilocode/daemon/client" @@ -15,6 +16,31 @@ afterEach(() => { }) describe("kilo tui thread", () => { + test("skips preload resolver invocation in compiled mode", () => { + let calls = 0 + + expect( + preload(true, () => { + calls++ + return "/resolved/preload" + }), + ).toEqual([]) + expect(calls).toBe(0) + }) + + test("resolves the preload once in source mode", () => { + let calls = 0 + const path = "/resolved/preload" + + expect( + preload(false, () => { + calls++ + return path + }), + ).toEqual([path]) + expect(calls).toBe(1) + }) + test("ignores stale PWD after cwd is changed by a process wrapper", async () => { await using root = await tmpdir() const pkg = path.join(root.path, "packages", "opencode") From b402cc2635c1dae836a684f9d7a981c05491a930 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:24:05 +0000 Subject: [PATCH 14/33] fix(gateway): keep openrouter auto models in chat list Allowlist openrouter/auto and openrouter/auto-beta so multimodal routers are not dropped by the image-output filter used for pure image models. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/openrouter-auto-chat-list.md | 6 ++ packages/kilo-gateway/src/api/models.ts | 7 +- packages/kilo-gateway/test/api/models.test.ts | 70 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 .changeset/openrouter-auto-chat-list.md diff --git a/.changeset/openrouter-auto-chat-list.md b/.changeset/openrouter-auto-chat-list.md new file mode 100644 index 0000000000..f951e396a1 --- /dev/null +++ b/.changeset/openrouter-auto-chat-list.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Show OpenRouter Auto and Auto Beta in the Kilo Gateway model list. diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 5c1d4b24f9..aaf3bd6169 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -67,6 +67,9 @@ const openRouterModelsResponseSchema = z.object({ type OpenRouterModel = z.infer +/** Multimodal chat routers that also advertise image output but are valid coding models. */ +const CHAT_IMAGE_OUTPUT_ALLOW = new Set(["openrouter/auto", "openrouter/auto-beta"]) + /** * Parse API price string to number, converting from per-token to per-million-tokens. * The API returns prices in $/token, but downstream cost calculation (getUsage) @@ -97,8 +100,8 @@ export async function fetchKiloModels(options?: { const models: Record = {} for (const model of raw.data) { - // Skip image generation models - if (model.architecture?.output_modalities?.includes("image")) { + // Skip image generation models (allowlisted multimodal routers still appear in chat) + if (model.architecture?.output_modalities?.includes("image") && !CHAT_IMAGE_OUTPUT_ALLOW.has(model.id)) { continue } diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 9bf34793b1..1edb464f65 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -248,3 +248,73 @@ test("returns error with kind=schema when response body is invalid JSON", async expect(result.models).toEqual({}) expect(result.error?.kind).toBe("schema") }) + +const MIXED_MODALITY_RESPONSE = JSON.stringify({ + data: [ + { + id: "openrouter/auto", + name: "Auto Router", + context_length: 2000000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text", "image"], + }, + supported_parameters: ["tools", "temperature"], + }, + { + id: "openrouter/auto-beta", + name: "Auto Router (Beta)", + context_length: 2000000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text", "image"], + }, + supported_parameters: ["tools", "temperature"], + }, + { + id: "black-forest-labs/flux-1.1-pro", + name: "FLUX 1.1 Pro", + context_length: 4096, + max_completion_tokens: 4096, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["image"], + }, + supported_parameters: ["tools"], + }, + { + id: "test/model-a", + name: "Test Model A", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "temperature"], + }, + ], +}) + +test("keeps openrouter auto routers with image output and drops pure image models", async () => { + const orig = globalThis.fetch + stubFetch( + async () => + new Response(MIXED_MODALITY_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(result.models["openrouter/auto"]).toBeDefined() + expect(result.models["openrouter/auto-beta"]).toBeDefined() + expect(result.models["test/model-a"]).toBeDefined() + expect(result.models["black-forest-labs/flux-1.1-pro"]).toBeUndefined() +}) From 7e8f4a7cbdc382a4cc98d1f83fb3262c25a835a8 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:31:18 +0000 Subject: [PATCH 15/33] fix(gateway): keep image-output models in chat list Stop filtering models by output_modalities so tool-capable image and multimodal models (including openrouter/auto) appear in the gateway list. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/openrouter-auto-chat-list.md | 2 +- packages/kilo-gateway/src/api/models.ts | 8 -------- packages/kilo-gateway/test/api/models.test.ts | 16 ++++++++++++++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.changeset/openrouter-auto-chat-list.md b/.changeset/openrouter-auto-chat-list.md index f951e396a1..5767e8e19c 100644 --- a/.changeset/openrouter-auto-chat-list.md +++ b/.changeset/openrouter-auto-chat-list.md @@ -3,4 +3,4 @@ "@kilocode/cli": patch --- -Show OpenRouter Auto and Auto Beta in the Kilo Gateway model list. +Include image-output models in the Kilo Gateway chat model list. diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index aaf3bd6169..eccf90afe9 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -67,9 +67,6 @@ const openRouterModelsResponseSchema = z.object({ type OpenRouterModel = z.infer -/** Multimodal chat routers that also advertise image output but are valid coding models. */ -const CHAT_IMAGE_OUTPUT_ALLOW = new Set(["openrouter/auto", "openrouter/auto-beta"]) - /** * Parse API price string to number, converting from per-token to per-million-tokens. * The API returns prices in $/token, but downstream cost calculation (getUsage) @@ -100,11 +97,6 @@ export async function fetchKiloModels(options?: { const models: Record = {} for (const model of raw.data) { - // Skip image generation models (allowlisted multimodal routers still appear in chat) - if (model.architecture?.output_modalities?.includes("image") && !CHAT_IMAGE_OUTPUT_ALLOW.has(model.id)) { - continue - } - // Skip models that don't support tools — Kilo requires tool calling if (!model.supported_parameters?.includes("tools")) { continue diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 1edb464f65..656d27eaf0 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -284,6 +284,17 @@ const MIXED_MODALITY_RESPONSE = JSON.stringify({ }, supported_parameters: ["tools"], }, + { + id: "test/no-tools", + name: "No Tools Model", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["temperature"], + }, { id: "test/model-a", name: "Test Model A", @@ -298,7 +309,7 @@ const MIXED_MODALITY_RESPONSE = JSON.stringify({ ], }) -test("keeps openrouter auto routers with image output and drops pure image models", async () => { +test("keeps image-output models with tools and drops models without tools", async () => { const orig = globalThis.fetch stubFetch( async () => @@ -315,6 +326,7 @@ test("keeps openrouter auto routers with image output and drops pure image model expect(result.error).toBeUndefined() expect(result.models["openrouter/auto"]).toBeDefined() expect(result.models["openrouter/auto-beta"]).toBeDefined() + expect(result.models["black-forest-labs/flux-1.1-pro"]).toBeDefined() expect(result.models["test/model-a"]).toBeDefined() - expect(result.models["black-forest-labs/flux-1.1-pro"]).toBeUndefined() + expect(result.models["test/no-tools"]).toBeUndefined() }) From 210a6bbf9b89b30b408d744cf0f253482370eed6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 20 Jul 2026 12:09:31 +0200 Subject: [PATCH 16/33] feat(agent-manager): effort selection in compare models, fix worktree prompt scrollbars --- .changeset/compare-models-effort.md | 5 ++ .changeset/worktree-prompt-scroll.md | 5 ++ .../src/agent-manager/multi-version.ts | 7 ++- .../kilo-vscode/src/agent-manager/types.ts | 2 +- .../tests/unit/multi-model-utils.test.ts | 19 +++++++ .../tests/unit/multi-version.test.ts | 57 +++++++++++++++++++ .../agent-manager/MultiModelSelector.tsx | 23 ++++++++ .../agent-manager/NewWorktreeDialog.tsx | 19 +++++-- .../agent-manager/agent-manager.css | 22 +++++-- .../webview-ui/agent-manager/i18n/ar.ts | 2 + .../webview-ui/agent-manager/i18n/br.ts | 2 + .../webview-ui/agent-manager/i18n/bs.ts | 2 + .../webview-ui/agent-manager/i18n/da.ts | 2 + .../webview-ui/agent-manager/i18n/de.ts | 2 + .../webview-ui/agent-manager/i18n/en.ts | 2 + .../webview-ui/agent-manager/i18n/es.ts | 2 + .../webview-ui/agent-manager/i18n/fr.ts | 2 + .../webview-ui/agent-manager/i18n/it.ts | 2 + .../webview-ui/agent-manager/i18n/ja.ts | 2 + .../webview-ui/agent-manager/i18n/ko.ts | 2 + .../webview-ui/agent-manager/i18n/nl.ts | 2 + .../webview-ui/agent-manager/i18n/no.ts | 2 + .../webview-ui/agent-manager/i18n/pl.ts | 2 + .../webview-ui/agent-manager/i18n/ru.ts | 2 + .../webview-ui/agent-manager/i18n/th.ts | 2 + .../webview-ui/agent-manager/i18n/tr.ts | 2 + .../webview-ui/agent-manager/i18n/uk.ts | 2 + .../webview-ui/agent-manager/i18n/zh.ts | 2 + .../webview-ui/agent-manager/i18n/zht.ts | 2 + .../agent-manager/multi-model-utils.ts | 17 +++++- .../src/types/messages/agent-manager.ts | 1 + 31 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 .changeset/compare-models-effort.md create mode 100644 .changeset/worktree-prompt-scroll.md create mode 100644 packages/kilo-vscode/tests/unit/multi-version.test.ts diff --git a/.changeset/compare-models-effort.md b/.changeset/compare-models-effort.md new file mode 100644 index 0000000000..b01e69d476 --- /dev/null +++ b/.changeset/compare-models-effort.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support choosing a reasoning effort per model in the Agent Manager Compare Models picker, so compared worktrees can run the same prompt at different effort levels. The selected effort is shown next to the model name in the collapsed selector. diff --git a/.changeset/worktree-prompt-scroll.md b/.changeset/worktree-prompt-scroll.md new file mode 100644 index 0000000000..7fbe3b4af5 --- /dev/null +++ b/.changeset/worktree-prompt-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix double scrollbars in the Agent Manager new-worktree prompt field and widen the dialog so longer prompts stay readable. The prompt box now grows with its content like the sidebar chat input, the textarea is the only element that scrolls, and manual resize of the prompt area keeps working. diff --git a/packages/kilo-vscode/src/agent-manager/multi-version.ts b/packages/kilo-vscode/src/agent-manager/multi-version.ts index 2ab92ad10e..5d0704f552 100644 --- a/packages/kilo-vscode/src/agent-manager/multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/multi-version.ts @@ -4,11 +4,13 @@ export interface ModelAllocation { providerID: string modelID: string count: number + variant?: string } interface ModelRef { providerID: string modelID: string + variant?: string } /** @@ -32,7 +34,7 @@ export function resolveVersionModels( for (const alloc of allocations) { const clamped = Math.min(Math.max(Math.floor(alloc.count) || 0, 0), MAX_MULTI_VERSIONS) for (let c = 0; c < clamped; c++) { - models.push({ providerID: alloc.providerID, modelID: alloc.modelID }) + models.push({ providerID: alloc.providerID, modelID: alloc.modelID, variant: alloc.variant }) } if (models.length >= MAX_MULTI_VERSIONS) break } @@ -98,7 +100,8 @@ export function buildInitialMessages( if (prompt) { msg.text = prompt msg.agent = agent - msg.variant = variant + // A per-allocation effort pick wins over the dialog-level variant. + msg.variant = model?.variant ?? variant msg.files = files } return msg diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 13d1e28e5b..63ade03fc9 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -445,7 +445,7 @@ interface CreateMultiVersionIn { files?: Array<{ mime: string; url: string }> baseBranch?: string branchName?: string - modelAllocations?: Array<{ providerID: string; modelID: string; count: number }> + modelAllocations?: Array<{ providerID: string; modelID: string; count: number; variant?: string }> /** When set, reconcile each created session's sandbox override to this state. */ sandbox?: boolean } diff --git a/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts b/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts index 175b74ae63..0c54e44644 100644 --- a/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/multi-model-utils.test.ts @@ -7,6 +7,7 @@ import { remaining, toggleModel, setAllocationCount, + setAllocationVariant, maxAllocationCount, MAX_MULTI_VERSIONS, } from "../../webview-ui/agent-manager/multi-model-utils" @@ -41,6 +42,24 @@ describe("multi-model-utils", () => { expect(arr).toContainEqual({ providerID: "b", modelID: "m2", count: 1 }) }) + test("allocationsToArray includes variant when set", () => { + const alloc = setAllocationVariant(make(["a", "m1", "Model 1", 1]), "a", "m1", "high") + expect(allocationsToArray(alloc)).toContainEqual({ providerID: "a", modelID: "m1", count: 1, variant: "high" }) + }) + + test("setAllocationVariant sets and clears the variant", () => { + const alloc = make(["a", "m1", "Model 1", 1]) + const set = setAllocationVariant(alloc, "a", "m1", "high") + expect(set.get("a/m1")?.variant).toBe("high") + expect(setAllocationVariant(set, "a", "m1", undefined).get("a/m1")?.variant).toBeUndefined() + }) + + test("setAllocationVariant preserves count and does nothing for unknown models", () => { + const alloc = make(["a", "m1", "Model 1", 2]) + expect(setAllocationVariant(alloc, "a", "m1", "high").get("a/m1")?.count).toBe(2) + expect(setAllocationVariant(alloc, "b", "m2", "high")).toBe(alloc) + }) + test("remaining returns slots left", () => { const alloc = make(["a", "m1", "Model 1", 2], ["b", "m2", "Model 2", 1]) expect(remaining(alloc)).toBe(MAX_MULTI_VERSIONS - 3) diff --git a/packages/kilo-vscode/tests/unit/multi-version.test.ts b/packages/kilo-vscode/tests/unit/multi-version.test.ts new file mode 100644 index 0000000000..9d5a1882ef --- /dev/null +++ b/packages/kilo-vscode/tests/unit/multi-version.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "../../src/agent-manager/multi-version" + +const created = (n: number): CreatedVersion[] => + Array.from({ length: n }, (_, i) => ({ + worktreeId: `wt-${i}`, + sessionId: `ses-${i}`, + path: `/tmp/wt-${i}`, + branch: `branch-${i}`, + parentBranch: "main", + versionIndex: i, + })) + +describe("resolveVersionModels", () => { + test("expands allocations with per-model variants", () => { + const { models, versions } = resolveVersionModels( + [ + { providerID: "a", modelID: "m1", count: 2, variant: "high" }, + { providerID: "b", modelID: "m2", count: 1 }, + ], + undefined, + 1, + ) + expect(versions).toBe(3) + expect(models).toEqual([ + { providerID: "a", modelID: "m1", variant: "high" }, + { providerID: "a", modelID: "m1", variant: "high" }, + { providerID: "b", modelID: "m2", variant: undefined }, + ]) + }) + + test("non-compare runs carry no per-version variant", () => { + const { models } = resolveVersionModels(undefined, { providerID: "a", modelID: "m1" }, 2) + expect(models).toEqual([]) + }) +}) + +describe("buildInitialMessages", () => { + test("per-allocation variant wins over the dialog-level variant", () => { + const models = resolveVersionModels( + [ + { providerID: "a", modelID: "m1", count: 1, variant: "high" }, + { providerID: "b", modelID: "m2", count: 1 }, + ], + undefined, + 1, + ).models + const msgs = buildInitialMessages(created(2), models, {}, "do it", undefined, "low") + expect(msgs[0]?.variant).toBe("high") + expect(msgs[1]?.variant).toBe("low") + }) + + test("falls back to the dialog-level variant when no allocation variant is set", () => { + const msgs = buildInitialMessages(created(1), [], { providerID: "a", modelID: "m1" }, "do it", undefined, "medium") + expect(msgs[0]?.variant).toBe("medium") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx index 71b39fed3c..e1d00c1f95 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/MultiModelSelector.tsx @@ -18,6 +18,7 @@ import { totalAllocations, toggleModel, setAllocationCount, + setAllocationVariant, maxAllocationCount, } from "./multi-model-utils" @@ -96,6 +97,7 @@ export const MultiModelSelector: Component<{ const checked = () => props.allocations.has(key()) const entry = () => props.allocations.get(key()) const disabled = () => !checked() && totalAllocations(props.allocations) >= MAX_MULTI_VERSIONS + const efforts = () => Object.keys(model.variants ?? {}) return (
+ 0}> + +