mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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>
|
||||
|
||||
+18
@@ -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") {
|
||||
|
||||
+22
@@ -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")
|
||||
|
||||
+20
@@ -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"))
|
||||
|
||||
+11
@@ -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) {
|
||||
|
||||
+6
-3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+136
@@ -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
|
||||
}
|
||||
}
|
||||
+138
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
+91
@@ -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)
|
||||
}
|
||||
}
|
||||
+89
@@ -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"))
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user