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