From 62923adb518371d1659ea65e5519768e4abf231b Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 7 Aug 2026 15:26:38 -0400 Subject: [PATCH] feat(jetbrains): add automatic editor context Include active editor files, open tabs, visible files, selected ranges, and shell metadata in JetBrains chat prompts so agents can infer the user's current editor context without manual attachment. Filter paths through .kilocodeignore or fallback gitignore/.env rules before sending them to the backend. --- .changeset/jetbrains-auto-editor-context.md | 5 + .../.run/Run IDE (Backend).run.xml | 2 +- .../.run/Run IDE (Frontend).run.xml | 2 +- .../.run/runIdeSplitMode.run.xml | 2 +- .../kilocode/backend/cli/KiloCliDataParser.kt | 18 +++ .../backend/cli/ChatLogSummaryTest.kt | 22 +++ .../backend/cli/KiloCliDataParserTest.kt | 20 +++ .../client/plugin/KiloPluginSettings.kt | 11 ++ .../ai/kilocode/client/session/SessionUi.kt | 9 +- .../session/context/EditorContextGatherer.kt | 136 +++++++++++++++++ .../client/session/context/KiloIgnore.kt | 138 ++++++++++++++++++ .../session/controller/SessionController.kt | 12 +- .../settings/context/ContextSettingsUi.kt | 14 +- .../resources/messages/KiloBundle.properties | 3 + .../context/EditorContextGathererTest.kt | 91 ++++++++++++ .../client/session/context/KiloIgnoreTest.kt | 89 +++++++++++ .../controller/EditorContextPromptTest.kt | 30 ++++ .../kotlin/ai/kilocode/log/ChatLogSummary.kt | 12 ++ .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 11 ++ 19 files changed, 617 insertions(+), 10 deletions(-) create mode 100644 .changeset/jetbrains-auto-editor-context.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt diff --git a/.changeset/jetbrains-auto-editor-context.md b/.changeset/jetbrains-auto-editor-context.md new file mode 100644 index 00000000000..3a6b91d758b --- /dev/null +++ b/.changeset/jetbrains-auto-editor-context.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Include the active editor file, open files, visible files, and selected text in JetBrains chat context by default, with a Context settings toggle to disable it. Files matched by `.kilocodeignore` (or `.gitignore` plus `.env` files) are excluded, and the default shell is reported to the agent. diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index af1ad2c5507..9e015ef7191 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -5,7 +5,7 @@ diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml index 8fd300cd6c4..fc04e21f7df 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -5,7 +5,7 @@ diff --git a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml index 5ab397fabf5..f709383cb2b 100644 --- a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml +++ b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml @@ -6,7 +6,7 @@ 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 09e112dca15..85c08422b65 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 @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageSummaryDto @@ -822,10 +823,27 @@ object KiloCliDataParser { if (variant != null) { sb.append(""","variant":${escape(variant)}""") } + val editor = prompt.editorContext + if (editor != null) { + sb.append(""","editorContext":${editorContextJson(editor)}""") + } sb.append("}") return sb.toString() } + private fun editorContextJson(ctx: EditorContextDto): String { + val fields = mutableListOf() + ctx.directory?.let { fields += "\"directory\":${escape(it)}" } + ctx.worktree?.let { fields += "\"worktree\":${escape(it)}" } + ctx.visibleFiles?.takeIf { it.isNotEmpty() }?.let { fields += "\"visibleFiles\":${array(it)}" } + ctx.openTabs?.takeIf { it.isNotEmpty() }?.let { fields += "\"openTabs\":${array(it)}" } + ctx.activeFile?.let { fields += "\"activeFile\":${escape(it)}" } + ctx.shell?.let { fields += "\"shell\":${escape(it)}" } + return "{${fields.joinToString(",")}}" + } + + private fun array(values: List): String = values.joinToString(",", "[", "]") { escape(it) } + private fun buildPromptPartJson(part: PromptPartDto): String { val fields = mutableListOf("\"type\":${escape(part.type)}") if (part.type == "file") { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt index 23afe339091..06c0259d1a1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.cli import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -156,6 +157,27 @@ class ChatLogSummaryTest { assertTrue(out.contains("variant=medium"), out) } + @Test + fun `prompt dto summary includes editor context`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + + val out = ChatLogSummary.prompt( + PromptDto( + parts = listOf(PromptPartDto(type = "text", text = "hello")), + editorContext = EditorContextDto( + activeFile = "settings.gradle", + openTabs = listOf("settings.gradle", "src/App.kt"), + visibleFiles = listOf("settings.gradle"), + ), + ) + ) + + assertTrue(out.contains("editorContext=true"), out) + assertTrue(out.contains("activeFile=\"settings.gradle\""), out) + assertTrue(out.contains("openTabs=2"), out) + assertTrue(out.contains("visibleFiles=1"), out) + } + @Test fun `prompt dto summary redacts file attachment urls`() { System.setProperty("kilo.dev.log.chat.content", "preview") 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 dab1d5b7138..132149fbfd7 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 @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto @@ -1958,6 +1959,25 @@ class KiloCliDataParserTest { assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result) } + @Test + fun `buildPromptJson - with editor context`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + editorContext = EditorContextDto( + activeFile = "src/App.kt", + visibleFiles = listOf("src/App.kt"), + openTabs = listOf("src/App.kt", "src/Other.kt"), + ), + ) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"text","text":"Hi"}],"editorContext":{"visibleFiles":["src/App.kt"],"openTabs":["src/App.kt","src/Other.kt"],"activeFile":"src/App.kt"}}""", + result, + ) + } + @Test fun `buildProviderOAuthJson - numeric method index`() { val result = KiloCliDataParser.buildProviderOAuthJson("0", mapOf("deploymentType" to "github.com")) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index 16641aab323..1a234402861 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -4,6 +4,7 @@ import com.intellij.ide.util.PropertiesComponent object KiloPluginSettings { private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove" + private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) @@ -16,6 +17,16 @@ object KiloPluginSettings { PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY) } + fun getAutoEditorContext(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_EDITOR_CONTEXT_KEY, true) + + fun setAutoEditorContext(value: Boolean) { + PropertiesComponent.getInstance().setValue(AUTO_EDITOR_CONTEXT_KEY, value.toString()) + } + + internal fun unsetAutoEditorContext() { + PropertiesComponent.getInstance().unsetValue(AUTO_EDITOR_CONTEXT_KEY) + } + fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false) fun setPermissionRulesExpanded(value: Boolean) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index a7b30a796c8..7635c282e9c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -47,6 +47,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.context.EditorContextGatherer import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.permission.PermissionView @@ -682,14 +683,16 @@ class SessionUi( private fun sendPrompt(text: String, files: List) { if (text.isBlank() && files.isEmpty()) return + val editor = EditorContextGatherer.gather(project, workspace.directory) + val allFiles = files + listOfNotNull(editor.selection) val parts = buildList { text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } - addAll(files) + addAll(allFiles) } LOG.debug { val agent = controller.model.agent ?: "none" val model = controller.model.model ?: "none" - "${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}" + "${ChatLogSummary.prompt(PromptDto(parts = parts, editorContext = editor.context))} agent=$agent model=$model ready=${controller.ready}" } prompt.clear() val follow = scroll.atBottom() @@ -705,7 +708,7 @@ class SessionUi( scroll.followBottom(follow) return } - controller.prompt(text, files) + controller.prompt(text, allFiles, editor.context) scroll.followBottom(follow) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt new file mode 100644 index 00000000000..747a663c21d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt @@ -0,0 +1,136 @@ +package ai.kilocode.client.session.context + +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.EditorContextDto +import ai.kilocode.rpc.dto.PromptPartDto +import com.intellij.codeWithMe.ClientId +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.impl.EditorHistoryManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.EnvironmentUtil +import java.nio.file.Path +import kotlin.io.path.name + +/** + * Reads the active/open editors and current selection at prompt-send time. + * + * Split mode caveat: the chat UI runs under a non-local [ClientId], so + * [FileEditorManager.getOpenFiles]/[FileEditorManager.getSelectedTextEditor] take + * the per-client branch and return nothing. The `*WithRemotes` variants read the + * local composites directly and are the ones that actually see the user's tabs. + * These APIs are `@ApiStatus.Experimental`. + */ +internal object EditorContextGatherer { + private val LOG = KiloLog.create(EditorContextGatherer::class.java) + + // Resolved once per process; the login shell does not change during a session. + private val shell: String? by lazy { + if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL") + } + + data class Result( + val context: EditorContextDto?, + val selection: PromptPartDto?, + ) + + fun gather(project: Project, root: String): Result { + if (!KiloPluginSettings.getAutoEditorContext()) { + LOG.debug { "kind=editor-context enabled=false" } + return Result(null, null) + } + val manager = FileEditorManager.getInstance(project) + val base = Path.of(root).toAbsolutePath().normalize() + val openFiles = manager.openFilesWithRemotes + val editor = manager.selectedTextEditorWithRemotes.firstOrNull() + val activeFile = editor?.let { file(it) } ?: lastOpen(project, openFiles) ?: openFiles.firstOrNull() + val ignore = KiloIgnore.load(rootDir(listOfNotNull(activeFile) + openFiles, base)) + + fun keep(file: VirtualFile?): String? = rel(file, base)?.takeUnless { ignore.ignored(it) } + + val active = keep(activeFile) + val openRel = openFiles.mapNotNull { rel(it, base) }.distinct() + val open = openRel.filterNot { ignore.ignored(it) }.take(20) + val visible = (listOfNotNull(activeFile) + manager.selectedTextEditorWithRemotes.mapNotNull { file(it) }) + .mapNotNull { keep(it) } + .distinct() + .take(200) + val ctx = EditorContextDto( + activeFile = active, + openTabs = open.takeIf { it.isNotEmpty() }, + visibleFiles = visible.takeIf { it.isNotEmpty() }, + shell = shell, + ).takeIf { active != null || open.isNotEmpty() || visible.isNotEmpty() || shell != null } + val part = editor?.let { selection(it, base, ignore) } + LOG.debug { + val first = openFiles.firstOrNull() + val filtered = openRel.count { ignore.ignored(it) } + "kind=editor-context enabled=true localId=${ClientId.isCurrentlyUnderLocalId}" + + " rawOpen=${openFiles.size} rawSel=${manager.selectedTextEditorWithRemotes.size}" + + " active=${active ?: "none"} open=${open.size} visible=${visible.size} selection=${part != null}" + + " ignored=$filtered shell=${shell ?: "none"}" + + " firstFs=${first?.fileSystem?.protocol ?: "none"} firstLocal=${first?.isInLocalFileSystem ?: false}" + + " firstPath=${first?.path ?: "none"}" + } + return Result(ctx, part) + } + + private fun lastOpen(project: Project, open: List): VirtualFile? { + val set = open.toHashSet() + return EditorHistoryManager.getInstance(project).fileList.lastOrNull { it in set } + } + + // Walks up from an open editor file to the workspace-root directory so ignore + // files can be read via the same (possibly remote) VFS as the editor files. + private fun rootDir(files: List, root: Path): VirtualFile? { + for (file in files) { + var cur: VirtualFile? = file + while (cur != null) { + if (runCatching { Path.of(cur.path).toAbsolutePath().normalize() }.getOrNull() == root) return cur + cur = cur.parent + } + } + return null + } + + private fun selection(editor: Editor, root: Path, ignore: KiloIgnore): PromptPartDto? { + val model = editor.selectionModel + if (!model.hasSelection()) return null + val file = file(editor) ?: return null + val path = local(file, root) ?: return null + if (ignore.ignored(root.relativize(path).toString())) return null + val start = model.selectionStart + val end = model.selectionEnd + if (start == end) return null + val doc = editor.document + val last = (end - 1).coerceAtLeast(start) + val first = doc.getLineNumber(start) + 1 + val line = doc.getLineNumber(last) + 1 + val url = "${path.toUri()}?start=$first&end=$line" + return PromptPartDto( + type = "file", + mime = "text/plain", + url = url, + filename = path.name, + ) + } + + private fun file(editor: Editor): VirtualFile? = FileDocumentManager.getInstance().getFile(editor.document) + + private fun rel(file: VirtualFile?, root: Path): String? { + val path = file?.let { local(it, root) } ?: return null + return root.relativize(path).toString() + } + + private fun local(file: VirtualFile, root: Path): Path? { + if (file.fileSystem.protocol == KiloVirtualFileSystem.PROTOCOL) return null + val path = Path.of(file.path).toAbsolutePath().normalize() + if (!path.startsWith(root)) return null + return path + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt new file mode 100644 index 00000000000..bef689e556b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt @@ -0,0 +1,138 @@ +package ai.kilocode.client.session.context + +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile + +/** + * Minimal `.gitignore`-style matcher used to keep ignored or sensitive files out + * of the editor context sent to the model. + * + * Mirrors the VS Code `FileIgnoreController` precedence: + * - if `.kilocodeignore` exists and is non-empty, use only its patterns (plus the + * `.kilocodeignore` file itself); + * - otherwise fall back to `.gitignore` plus the sensitive `.env` / `.env.*` + * patterns. + * + * Paths are matched as workspace-relative POSIX paths. Only the subset of gitignore + * syntax relevant to path filtering is supported: comments (`#`), blank lines, + * negation (`!`), anchoring (leading or embedded `/`), directory-only (trailing + * `/`), and the `*`, `**`, `?`, and `[..]` globs. + */ +internal class KiloIgnore private constructor(private val rules: List) { + + /** True when [path] (workspace-relative) should be excluded from editor context. */ + fun ignored(path: String): Boolean { + val norm = path.replace('\\', '/').trim('/') + if (norm.isEmpty()) return false + var hit = false + for (rule in rules) { + if (rule.regex.matches(norm)) hit = !rule.negate + } + return hit + } + + private class Rule(val regex: Regex, val negate: Boolean) + + companion object { + val EMPTY = KiloIgnore(emptyList()) + + private const val KILO = ".kilocodeignore" + private const val GIT = ".gitignore" + private val SENSITIVE = listOf(".env", ".env.*") + + /** + * Builds the matcher from the ignore files under [root]. Reads through the VFS + * so it works in remote/split mode where the workspace lives on the host. + * Returns [EMPTY] (allow-all) when [root] is null or unreadable; the backend + * permission layer still guards file contents. + */ + fun load(root: VirtualFile?): KiloIgnore { + if (root == null) return EMPTY + val kilo = read(root, KILO) + if (!kilo.isNullOrBlank()) return KiloIgnore(compile(kilo) + compile(KILO)) + val rules = mutableListOf() + read(root, GIT)?.takeIf { it.isNotBlank() }?.let { rules += compile(it) } + rules += SENSITIVE.mapNotNull { rule(it) } + return KiloIgnore(rules) + } + + /** Test seam: build a matcher directly from ignore-file text. */ + fun of(text: String): KiloIgnore = KiloIgnore(compile(text)) + + private fun read(root: VirtualFile, name: String): String? { + val file = root.findChild(name) ?: return null + if (!file.isValid || file.isDirectory) return null + return runCatching { VfsUtilCore.loadText(file) }.getOrNull() + } + + private fun compile(text: String): List = text.lineSequence().mapNotNull { rule(it) }.toList() + + private fun rule(raw: String): Rule? { + var line = raw.trimEnd() + if (line.isEmpty() || line.startsWith("#")) return null + val negate = line.startsWith("!") + if (negate) line = line.substring(1) + val dirOnly = line.endsWith("/") + if (dirOnly) line = line.trimEnd('/') + val leading = line.startsWith("/") + if (leading) line = line.trimStart('/') + if (line.isEmpty()) return null + val anchored = leading || line.contains('/') + val prefix = if (anchored) "" else "(?:.*/)?" + val suffix = if (dirOnly) "/.*" else "(?:/.*)?" + return Rule(Regex("^$prefix${glob(line)}$suffix$"), negate) + } + + private fun glob(glob: String): String { + val sb = StringBuilder() + var i = 0 + while (i < glob.length) { + val c = glob[i] + when (c) { + '\\' -> { + val next = glob.getOrNull(i + 1) + if (next == null) sb.append("\\\\") + else { + if (!next.isLetterOrDigit()) sb.append('\\') + sb.append(next) + i++ + } + } + + '*' -> { + if (glob.getOrNull(i + 1) == '*') { + i++ + if (glob.getOrNull(i + 1) == '/') { + sb.append("(?:.*/)?") + i++ + } else { + sb.append(".*") + } + } else { + sb.append("[^/]*") + } + } + + '?' -> sb.append("[^/]") + + '[' -> { + val end = glob.indexOf(']', i + 1) + if (end == -1) { + sb.append("\\[") + } else { + val body = glob.substring(i + 1, end) + sb.append('[').append(if (body.startsWith("!")) "^${body.substring(1)}" else body).append(']') + i = end + } + } + + '.', '(', ')', '+', '|', '^', '$', '{', '}', ']' -> sb.append('\\').append(c) + + else -> sb.append(c) + } + i++ + } + return sb.toString() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a0f0013b98a..0ae42b857bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -31,6 +31,7 @@ import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -261,11 +262,11 @@ class SessionController( } } - fun prompt(text: String, files: List = emptyList()) { + fun prompt(text: String, files: List = emptyList(), editorContext: EditorContextDto? = null) { assertEdt() val start = sid ?: ref?.key ?: "pending" val exists = sid != null - val dto = promptDto(text, files) + val dto = promptDto(text, files, editorContext) val props = promptProps(files) LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" } dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id -> @@ -1820,7 +1821,11 @@ class SessionController( } } - private fun promptDto(text: String, files: List = emptyList()): PromptDto { + private fun promptDto( + text: String, + files: List = emptyList(), + editorContext: EditorContextDto? = null, + ): PromptDto { val full = model.model val sel = full?.let(::parseModel) val variant = model.variant?.takeIf { it in model.variants } @@ -1834,6 +1839,7 @@ class SessionController( modelID = sel?.second, agent = model.agent, variant = variant, + editorContext = editorContext, ) } 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 7a66f2ac59a..0118542137d 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 @@ -3,6 +3,7 @@ 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.plugin.KiloPluginSettings import ai.kilocode.client.settings.base.BaseContentPanel import ai.kilocode.client.settings.base.BaseSettingsUi import ai.kilocode.client.settings.base.SettingsBannerKind @@ -134,6 +135,9 @@ internal class ContextSettingsContent( private val update: (ContextDraft.() -> ContextDraft) -> Unit, ) : BaseContentPanel() { private val auto = SettingsToggle { value -> update { copy(auto = value) } } + private val editor = SettingsToggle(KiloPluginSettings.getAutoEditorContext()) { value -> + KiloPluginSettings.setAutoEditorContext(value) + } private val prune = SettingsToggle { value -> update { copy(prune = value) } } private val threshold = ThresholdField( KiloBundle.message("settings.context.compaction.threshold.placeholder"), @@ -163,6 +167,13 @@ internal class ContextSettingsContent( prune, )) } + section( + KiloBundle.message("settings.context.editor.title"), + ).row(SettingsRow( + KiloBundle.message("settings.context.editor.auto.title"), + KiloBundle.message("settings.context.editor.auto.description"), + editor, + )) section( KiloBundle.message("settings.context.watcher.title"), KiloBundle.message("settings.context.watcher.description"), @@ -172,10 +183,11 @@ internal class ContextSettingsContent( @RequiresEdt fun sync(draft: ContextDraft, enabled: Boolean) { auto.isSelected = draft.auto + editor.isSelected = KiloPluginSettings.getAutoEditorContext() prune.isSelected = draft.prune threshold.sync(draft.threshold) patterns.sync(draft.ignore) - listOf(auto, prune, threshold, patterns).forEach { it.isEnabled = enabled } + listOf(auto, editor, prune, threshold, patterns).forEach { it.isEnabled = enabled } } } 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 074f5942042..f03000a254e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -363,6 +363,9 @@ 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 +settings.context.editor.title=Editor Context +settings.context.editor.auto.title=Auto-Include Editor Context +settings.context.editor.auto.description=Include the active file, open files, visible files, and selected text when sending chat messages. settings.context.watcher.title=File Watcher Ignore Patterns settings.context.watcher.description=Glob patterns for files the watcher should ignore settings.context.watcher.add=Add pattern diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt new file mode 100644 index 00000000000..2e7fd549490 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.context + +import ai.kilocode.client.plugin.KiloPluginSettings +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.SystemInfo +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.EnvironmentUtil +import com.intellij.util.ui.UIUtil + +class EditorContextGathererTest : BasePlatformTestCase() { + override fun tearDown() { + try { + KiloPluginSettings.unsetAutoEditorContext() + } finally { + super.tearDown() + } + } + + fun `test gather includes active open visible files and selected range`() { + val psi = myFixture.addFileToProject( + "src/App.kt", + "fun main() {\n println(\"hi\")\n println(\"bye\")\n}\n", + ) + val manager = FileEditorManager.getInstance(project) + manager.openFile(psi.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val editor = manager.selectedTextEditor!! + val doc = editor.document + editor.selectionModel.setSelection(doc.getLineStartOffset(1), doc.getLineEndOffset(2)) + val root = psi.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertEquals("src/App.kt", result.context?.activeFile) + assertEquals(listOf("src/App.kt"), result.context?.openTabs) + assertEquals(listOf("src/App.kt"), result.context?.visibleFiles) + assertEquals("text/plain", result.selection?.mime) + assertEquals("App.kt", result.selection?.filename) + assertTrue(result.selection?.url, result.selection?.url.orEmpty().contains("/src/App.kt?start=2&end=3")) + val expectedShell = if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL") + assertEquals(expectedShell, result.context?.shell) + } + + fun `test gather filters kilocodeignore files from open tabs`() { + val app = myFixture.addFileToProject("src/App.kt", "fun main() {}") + val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1") + myFixture.addFileToProject(".kilocodeignore", "ignored/\n") + val manager = FileEditorManager.getInstance(project) + manager.openFile(secret.virtualFile, true) + manager.openFile(app.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val root = app.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertEquals("src/App.kt", result.context?.activeFile) + assertEquals(listOf("src/App.kt"), result.context?.openTabs) + assertEquals(listOf("src/App.kt"), result.context?.visibleFiles) + } + + fun `test gather drops selection when active file is ignored`() { + val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1\nval other = 2\n") + myFixture.addFileToProject(".kilocodeignore", "ignored/\n") + val manager = FileEditorManager.getInstance(project) + manager.openFile(secret.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val editor = manager.selectedTextEditor!! + val doc = editor.document + editor.selectionModel.setSelection(doc.getLineStartOffset(0), doc.getLineEndOffset(0)) + val root = secret.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertNull(result.context?.activeFile) + assertNull(result.context?.openTabs) + assertNull(result.context?.visibleFiles) + assertNull(result.selection) + } + + fun `test gather returns empty when setting is off`() { + KiloPluginSettings.setAutoEditorContext(false) + val psi = myFixture.addFileToProject("src/App.kt", "fun main() {}") + FileEditorManager.getInstance(project).openFile(psi.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + + val result = EditorContextGatherer.gather(project, psi.virtualFile.parent.parent.path) + + assertNull(result.context) + assertNull(result.selection) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt new file mode 100644 index 00000000000..fbe71fa5a89 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.context + +import junit.framework.TestCase + +class KiloIgnoreTest : TestCase() { + fun `test empty allows everything`() { + val ignore = KiloIgnore.of("") + assertFalse(ignore.ignored("src/App.kt")) + assertFalse(ignore.ignored(".env")) + } + + fun `test basename matches at any depth`() { + val ignore = KiloIgnore.of("foo") + assertTrue(ignore.ignored("foo")) + assertTrue(ignore.ignored("a/b/foo")) + assertTrue(ignore.ignored("foo/child.txt")) + assertFalse(ignore.ignored("a/foobar")) + } + + fun `test extension glob`() { + val ignore = KiloIgnore.of("*.log") + assertTrue(ignore.ignored("a.log")) + assertTrue(ignore.ignored("nested/dir/a.log")) + assertFalse(ignore.ignored("a.log.kt")) + } + + fun `test directory only pattern matches contents`() { + val ignore = KiloIgnore.of("node_modules/") + assertTrue(ignore.ignored("node_modules/pkg/index.js")) + assertTrue(ignore.ignored("a/node_modules/pkg.js")) + assertFalse(ignore.ignored("node_modules")) + } + + fun `test leading slash anchors to root`() { + val ignore = KiloIgnore.of("/build") + assertTrue(ignore.ignored("build/out.js")) + assertFalse(ignore.ignored("src/build/out.js")) + } + + fun `test middle slash anchors to root`() { + val ignore = KiloIgnore.of("src/generated") + assertTrue(ignore.ignored("src/generated/A.kt")) + assertFalse(ignore.ignored("app/src/generated/A.kt")) + } + + fun `test double star matches across directories`() { + val ignore = KiloIgnore.of("**/dist") + assertTrue(ignore.ignored("dist/a.js")) + assertTrue(ignore.ignored("a/b/dist/a.js")) + + val nested = KiloIgnore.of("src/**/*.tmp") + assertTrue(nested.ignored("src/a/b/c.tmp")) + assertTrue(nested.ignored("src/x.tmp")) + assertFalse(nested.ignored("lib/a.tmp")) + } + + fun `test negation re-includes`() { + val ignore = KiloIgnore.of("*.log\n!keep.log") + assertTrue(ignore.ignored("debug.log")) + assertFalse(ignore.ignored("keep.log")) + } + + fun `test comments and blank lines ignored`() { + val ignore = KiloIgnore.of("# a comment\n\n*.secret\n") + assertTrue(ignore.ignored("api.secret")) + assertFalse(ignore.ignored("# a comment")) + } + + fun `test sensitive env patterns`() { + val ignore = KiloIgnore.of(".env\n.env.*") + assertTrue(ignore.ignored(".env")) + assertTrue(ignore.ignored(".env.local")) + assertTrue(ignore.ignored("cfg/.env.production")) + assertFalse(ignore.ignored("env")) + assertFalse(ignore.ignored("environment.ts")) + } + + fun `test char class`() { + val ignore = KiloIgnore.of("*.[oa]") + assertTrue(ignore.ignored("main.o")) + assertTrue(ignore.ignored("lib.a")) + assertFalse(ignore.ignored("main.c")) + } + + fun `test backslash separators normalized`() { + val ignore = KiloIgnore.of("node_modules/") + assertTrue(ignore.ignored("a\\node_modules\\pkg.js")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt new file mode 100644 index 00000000000..53cd3e1b05f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt @@ -0,0 +1,30 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.EditorContextDto +import ai.kilocode.rpc.dto.PromptPartDto +import kotlin.test.assertEquals + +class EditorContextPromptTest : SessionControllerTestBase() { + fun `test prompt forwards editor context`() { + val (c, _, _) = prompted() + rpc.prompts.clear() + val ctx = EditorContextDto( + activeFile = "src/App.kt", + openTabs = listOf("src/App.kt"), + visibleFiles = listOf("src/App.kt"), + ) + val file = PromptPartDto( + type = "file", + mime = "text/plain", + url = "file:///test/src/App.kt?start=2&end=3", + filename = "App.kt", + ) + + edt { c.prompt("explain", listOf(file), ctx) } + flush() + + val prompt = rpc.prompts.single().third + assertEquals(ctx, prompt.editorContext) + assertEquals(file, prompt.parts.first { it.type == "file" }) + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 588c0c9d0e6..c6e1f3cc34e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -72,10 +72,22 @@ object ChatLogSummary { prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" } model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" } prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" } + prompt.editorContext?.let { ctx -> + out += "editorContext=true" + ctx.activeFile?.let { file -> out += editorFile("activeFile", file) } + ctx.openTabs?.size?.takeIf { it > 0 }?.let { out += "openTabs=$it" } + ctx.visibleFiles?.size?.takeIf { it > 0 }?.let { out += "visibleFiles=$it" } + ctx.shell?.takeIf { it.isNotBlank() }?.let { out += "shell=$it" } + } preview(text)?.let { out += "preview=\"$it\"" } return out.joinToString(" ") } + private fun editorFile(key: String, file: String): String { + if (mode() == Mode.OFF) return "${key}Hash=${hash(file)}" + return "$key=\"${clean(file)}\"" + } + fun history(items: List): String { val out = mutableListOf() val parts = items.sumOf { it.parts.size } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index ecf0da6a410..5e21bfdd98c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -122,6 +122,17 @@ data class PromptDto( val agent: String? = null, val variant: String? = null, val noReply: Boolean? = null, + val editorContext: EditorContextDto? = null, +) + +@Serializable +data class EditorContextDto( + val directory: String? = null, + val worktree: String? = null, + val visibleFiles: List? = null, + val openTabs: List? = null, + val activeFile: String? = null, + val shell: String? = null, ) @Serializable