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