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.
This commit is contained in:
kirillk
2026-08-07 15:26:38 -04:00
parent 6b6b120588
commit 62923adb51
19 changed files with 617 additions and 10 deletions
@@ -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.
@@ -5,7 +5,7 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-configuration-cache --purge-old-log-directories -Pkilo.dev.log.level=debug -Pkilo.splitModeServerPort=0 -Pkilo.dev.storage.isolated=true" />
<option name="scriptParameters" value="--no-configuration-cache --purge-old-log-directories -Pkilo.dev.log.level=debug -Pkilo.dev.log.chat.content=off -Pkilo.dev.log.chat.preview.max=160 -Pkilo.splitModeServerPort=0 -Pkilo.dev.storage.isolated=true" />
<option name="taskDescriptions">
<list />
</option>
@@ -5,7 +5,7 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-configuration-cache --purge-old-log-directories -Pkilo.dev.log.level=debug -Pkilo.splitModeServerPort=0 -Pkilo.dev.storage.isolated=true" />
<option name="scriptParameters" value="--no-configuration-cache --purge-old-log-directories -Pkilo.dev.log.level=debug -Pkilo.dev.log.chat.content=off -Pkilo.dev.log.chat.preview.max=160 -Pkilo.splitModeServerPort=0 -Pkilo.dev.storage.isolated=true" />
<option name="taskDescriptions">
<list />
</option>
@@ -6,7 +6,7 @@
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-configuration-cache --purge-old-log-directories -Pkilo.dev.log.level=debug -Pkilo.splitModeServerPort=0 -Pkilo.dev.storage.isolated=true" />
<option name="scriptParameters" value="--no-configuration-cache --purge-old-log-directories -Pkilo.dev.log.level=debug -Pkilo.dev.log.chat.content=off -Pkilo.dev.log.chat.preview.max=160 -Pkilo.splitModeServerPort=0 -Pkilo.dev.storage.isolated=true" />
<option name="taskDescriptions">
<list />
</option>
@@ -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<String>()
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>): String = values.joinToString(",", "[", "]") { escape(it) }
private fun buildPromptPartJson(part: PromptPartDto): String {
val fields = mutableListOf("\"type\":${escape(part.type)}")
if (part.type == "file") {
@@ -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")
@@ -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"))
@@ -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) {
@@ -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<PromptPartDto>) {
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)
}
@@ -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>): 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<VirtualFile>, 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
}
}
@@ -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<Rule>) {
/** 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<Rule>()
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<Rule> = 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()
}
}
}
@@ -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<PromptPartDto> = emptyList()) {
fun prompt(text: String, files: List<PromptPartDto> = 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<PromptPartDto> = emptyList()): PromptDto {
private fun promptDto(
text: String,
files: List<PromptPartDto> = 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,
)
}
@@ -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 }
}
}
@@ -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
@@ -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)
}
}
@@ -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"))
}
}
@@ -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" })
}
}
@@ -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<MessageWithPartsDto>): String {
val out = mutableListOf<String>()
val parts = items.sumOf { it.parts.size }
@@ -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<String>? = null,
val openTabs: List<String>? = null,
val activeFile: String? = null,
val shell: String? = null,
)
@Serializable