Merge pull request #13015 from Kilo-Org/colossal-wizard

feat(jetbrains): add editor context and prompt attachments
This commit is contained in:
Kirill Kalishev
2026-08-10 16:12:21 -04:00
committed by GitHub
54 changed files with 1412 additions and 160 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.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render prompt attachments inside the sent message bubble with file chips, image previews, and selection-aware file opening.
@@ -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") {
@@ -26,6 +26,8 @@ import com.intellij.execution.process.CapturingProcessHandler
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.editor.ScrollType
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
@@ -288,7 +290,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
}.ifBlank { null }
}
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false
@@ -296,7 +298,7 @@ class KiloWorkspaceRpcApiImpl internal constructor(
LOG.warn("No project available to open file: $path")
return false
}
navigate(project, vf, line, column)
navigate(project, vf, line, column, endLine)
return true
}
@@ -374,8 +376,26 @@ class KiloWorkspaceRpcApiImpl internal constructor(
null
}
private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null) = suspendCancellableCoroutine { cont ->
private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null, endLine: Int? = null) = suspendCancellableCoroutine { cont ->
ApplicationManager.getApplication().invokeLater({
if (line != null && endLine != null) {
val editor = FileEditorManager.getInstance(project).openTextEditor(
OpenFileDescriptor(project, file, (line - 1).coerceAtLeast(0), 0),
true,
)
val doc = editor?.document
if (editor != null && doc != null && doc.lineCount > 0) {
val start = (line - 1).coerceIn(0, doc.lineCount - 1)
val end = (endLine - 1).coerceIn(start, doc.lineCount - 1)
val from = doc.getLineStartOffset(start)
val to = doc.getLineEndOffset(end)
editor.selectionModel.setSelection(from, to)
editor.caretModel.moveToOffset(from)
editor.scrollingModel.scrollToCaret(ScrollType.CENTER)
}
if (cont.isActive) cont.resume(Unit)
return@invokeLater
}
val descriptor = if (line == null) {
OpenFileDescriptor(project, file)
} else {
@@ -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"))
@@ -179,19 +179,19 @@ class KiloWorkspaceService internal constructor(
}
}
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean {
suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
call { openFile(match.path, line, column) }
call { openFile(match.path, line, column, endLine) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=${match.path}", e)
false
}
}
suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean {
suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean {
return try {
call { openFile(path, line, column) }
call { openFile(path, line, column, endLine) }
} catch (e: Exception) {
LOG.warn("workspace file open failed for path=$path", e)
false
@@ -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) {
@@ -59,7 +59,7 @@ class SessionFileLinks(
}
val target = parse(href)
scope.launch {
val ok = service.openPath(dir, target.path, target.line, target.column)
val ok = service.openPath(dir, target.path, target.line, target.column, target.endLine)
if (ok) {
track(target, "direct")
return@launch
@@ -72,7 +72,7 @@ class SessionFileLinks(
when (val result = decide(false, found)) {
Resolution.Opened -> Unit
is Resolution.OpenDirect -> {
val opened = service.openPath(dir, result.file.path, target.line, target.column)
val opened = service.openPath(dir, result.file.path, target.line, target.column, target.endLine)
track(target, if (opened) "search_direct" else "missing")
}
is Resolution.Choose -> {
@@ -104,7 +104,7 @@ class SessionFileLinks(
.createPopupChooserBuilder(files)
.setRenderer(FileRenderer())
.setItemChosenCallback { file ->
scope.launch { service.openPath(dir, file.path, target.line, target.column) }
scope.launch { service.openPath(dir, file.path, target.line, target.column, target.endLine) }
}
.createPopup()
popup.show(anchor ?: RelativePoint.getCenterOf(root))
@@ -143,11 +143,11 @@ class SessionFileLinks(
data object Missing : Resolution
}
data class Target(val path: String, val line: Int? = null, val column: Int? = null)
data class Target(val path: String, val line: Int? = null, val column: Int? = null, val endLine: Int? = null)
companion object {
private const val FILE_SEARCH_LIMIT = 50
private val LINE = Regex(":(\\d+)(?:-\\d+)?(?::(\\d+))?$")
private val LINE = Regex(":(\\d+)(?:-(\\d+))?(?::(\\d+))?$")
private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):")
fun parse(href: String): Target {
@@ -155,6 +155,7 @@ class SessionFileLinks(
return Target(
href.substring(0, match.range.first),
match.groupValues[1].toIntOrNull(),
match.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }?.toIntOrNull(),
match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull(),
)
}
@@ -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
@@ -155,7 +156,7 @@ class SessionUi(
condense = Registry.`is`("kilo.session.condense", true),
displayMs = displayMs,
open = { item -> manager?.openSession(item) },
beforeUpdate = { if (opening) false else scroll.atBottom() },
beforeUpdate = { if (opening) false else scroll.following() },
afterUpdate = { if (!opening) scroll.followBottom(it) },
loaded = ::onSessionLoaded,
openProfileAction = ::openProfileSettings,
@@ -682,17 +683,8 @@ class SessionUi(
private fun sendPrompt(text: String, files: List<PromptPartDto>) {
if (text.isBlank() && files.isEmpty()) return
val parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(files)
}
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}"
}
prompt.clear()
val follow = scroll.atBottom()
val follow = scroll.following()
val action = completion.clientAction(text)
if (action != null) {
action.action()
@@ -705,7 +697,20 @@ class SessionUi(
scroll.followBottom(follow)
return
}
controller.prompt(text, files)
// Only the prompt path uses editor context; gather after the command branches so slash
// commands and client actions don't pay the editor-context cost or hit its failure modes.
val editor = EditorContextGatherer.gather(project, workspace.directory)
val allFiles = files + listOfNotNull(editor.selection)
LOG.debug {
val parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(allFiles)
}
val agent = controller.model.agent ?: "none"
val model = controller.model.model ?: "none"
"${ChatLogSummary.prompt(PromptDto(parts = parts, editorContext = editor.context))} agent=$agent model=$model ready=${controller.ready}"
}
controller.prompt(text, allFiles, editor.context)
scroll.followBottom(follow)
}
@@ -918,12 +923,13 @@ class SessionUi(
return
}
if (uri.scheme == "file") {
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run {
val path = runCatching { Path.of(cleanAttachmentUri(uri)).toString() }.getOrNull() ?: run {
LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
return
}
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path")
fileLinks.open(path, null)
val target = attachmentHref(path, item)
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$target")
fileLinks.open(target, null)
return
}
LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}")
@@ -934,6 +940,14 @@ class SessionUi(
?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() }
?: "attachment"
private fun attachmentHref(path: String, item: FileAttachment): String {
val start = item.startLine ?: return path
val end = item.endLine ?: start
return "$path:$start-$end"
}
private fun cleanAttachmentUri(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null)
private fun attachmentUrl(url: String): String {
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}"
@@ -0,0 +1,139 @@
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.components.service
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 = project.service<KiloIgnoreCache>().matcher(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
// A host filename that is invalid on the client OS (e.g. `?`/`*` from a Linux host on
// a Windows frontend) throws InvalidPathException; drop the file instead of failing the send.
val path = runCatching { Path.of(file.path).toAbsolutePath().normalize() }.getOrNull() ?: return null
if (!path.startsWith(root)) return null
return path
}
}
@@ -0,0 +1,141 @@
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())
const val KILO = ".kilocodeignore"
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 "(?:/.*)?"
// A malformed character class (e.g. `[]`, `[z-a]`) yields an invalid Java regex.
// Skip the bad rule instead of letting PatternSyntaxException break every prompt send.
val regex = runCatching { Regex("^$prefix${glob(line)}$suffix$") }.getOrNull() ?: return null
return Rule(regex, 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()
}
}
}
@@ -0,0 +1,46 @@
package ai.kilocode.client.session.context
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import java.util.concurrent.ConcurrentHashMap
/**
* Caches the compiled [KiloIgnore] per workspace-root directory so editor-context
* gathering does not re-read and re-compile the ignore files on every prompt.
*
* The compiled matcher is reused until a `.kilocodeignore` or `.gitignore` change
* invalidates it via a VFS listener. This keeps the blocking VFS read (a remote `cwm`
* round-trip in split mode) off the prompt-send path after the first prompt, instead of
* repeating it for every message the user sends.
*/
@Service(Service.Level.PROJECT)
internal class KiloIgnoreCache : Disposable {
private val cache = ConcurrentHashMap<String, KiloIgnore>()
init {
ApplicationManager.getApplication().messageBus.connect(this)
.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
override fun after(events: List<VFileEvent>) {
if (events.any { relevant(it) }) cache.clear()
}
})
}
/** Returns the cached matcher for [root], compiling and caching it on first use. */
fun matcher(root: VirtualFile?): KiloIgnore {
if (root == null) return KiloIgnore.EMPTY
return cache.getOrPut(root.url) { KiloIgnore.load(root) }
}
override fun dispose() = cache.clear()
private fun relevant(event: VFileEvent): Boolean {
val name = event.path.substringAfterLast('/')
return name == KiloIgnore.KILO || name == KiloIgnore.GIT
}
}
@@ -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,
)
}
@@ -73,6 +73,8 @@ class FileAttachment(id: String) : Content(id) {
var url: String = ""
var filename: String? = null
var source: PartSourceDto? = null
var startLine: Int? = null
var endLine: Int? = null
}
/** Tool invocation with lifecycle state. */
@@ -498,6 +498,9 @@ class SessionModel {
existing.url = dto.url ?: ""
existing.filename = dto.filename
existing.source = dto.source
val range = range(existing.url)
existing.startLine = range?.first
existing.endLine = range?.last
}
is Tool -> {
val old = existing.childSessionId
@@ -552,6 +555,9 @@ class SessionModel {
url = dto.url ?: ""
filename = dto.filename
source = dto.source
val range = range(url)
startLine = range?.first
endLine = range?.last
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
messageID = dto.messageID
@@ -581,6 +587,20 @@ class SessionModel {
for (l in listeners) l.onEvent(event)
}
private fun range(url: String): IntRange? {
val query = runCatching { java.net.URI.create(url).rawQuery }.getOrNull() ?: return null
val args = query.split('&')
.mapNotNull {
val index = it.indexOf('=')
if (index < 0) return@mapNotNull null
it.substring(0, index) to it.substring(index + 1)
}
.toMap()
val start = args["start"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null
val end = args["end"]?.toIntOrNull()?.takeIf { it >= start } ?: start
return start..end
}
private fun trackChild(messageId: String, content: Content) {
val tool = content as? Tool ?: return
val child = tool.childSessionId ?: return
@@ -0,0 +1,14 @@
package ai.kilocode.client.session.ui
import com.intellij.xml.util.XmlStringUtil
internal fun fileLinkText(value: String): String = value.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(" ")
internal fun fileLinkHtml(value: String): String {
val text = fileLinkText(value)
if (text.isBlank()) return ""
return XmlStringUtil.wrapInHtml("<nobr><u>${XmlStringUtil.escapeString(text)}</u></nobr>")
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.ui.attachment
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.fileLinkHtml
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
@@ -47,6 +48,62 @@ data class AttachmentCardItem(
val path: Path? = null,
)
class AttachmentChip(
private val item: AttachmentCardItem,
private val file: Boolean,
private val startLine: Int? = null,
private val endLine: Int? = null,
open: (() -> Unit)? = null,
) : JPanel(BorderLayout()) {
private val tip = tooltip(item)
private val open = open?.let { callback ->
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
callback()
}
}
}
init {
isOpaque = false
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
toolTipText = tip
accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name)
val label = JBLabel(fileLinkHtml(label())).apply {
icon = attachmentIcon(item.mime, item.name)
iconTextGap = JBUI.scale(SessionUiStyle.View.Attachment.CHIP_ICON_GAP)
toolTipText = tip
}
add(label, BorderLayout.CENTER)
watch(this)
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
return Dimension(size.width, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HEIGHT))
}
override fun getMinimumSize(): Dimension = preferredSize
private fun label(): String {
val start = startLine
val end = endLine
if (file && start != null && end != null) return KiloBundle.message("session.attachment.file.range", item.name, start, end)
if (file) return item.name
return KiloBundle.message("session.attachment.unknown", item.mime.ifBlank { "unknown" })
}
private fun watch(node: Component) {
if (node is JComponent) node.toolTipText = tip
open?.let {
node.removeMouseListener(it)
node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
node.addMouseListener(it)
}
if (node is Container) node.components.forEach(::watch)
}
}
open class AttachmentCard(
private val item: AttachmentCardItem,
remove: (() -> Unit)? = null,
@@ -23,6 +23,9 @@ class PromptAttachmentStrip(
private val chips = LinkedHashMap<String, PromptAttachmentChip>()
init {
// Transparent so the prompt shell background shows through instead of the strip
// painting its own panel background above the input surface.
isOpaque = false
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
isVisible = false
}
@@ -42,6 +42,7 @@ import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.TextAttributesKey
@@ -126,11 +127,14 @@ class PromptPanel(
private val INVALID_KEY = CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES
}
val mode = ModePicker()
// Prompt-bar pickers blend into the prompt background when idle and only show the standard
// hover fill on pointer-over (idleFill = null paints nothing behind the label).
val mode = ModePicker().apply { idleFill = null }
val model = ModelPicker().apply {
placement = ModelPicker.Placement.ABOVE
idleFill = null
}
val reasoning = ReasoningPicker()
val reasoning = ReasoningPicker().apply { idleFill = null }
var onReset: () -> Unit = {}
var onChange: () -> Unit = {}
var onAutoApproveToggle: (Boolean) -> Unit = {}
@@ -256,6 +260,7 @@ class PromptPanel(
private var ready = false
private var enhancing = false
private var request = 0L
private var deferred = false
override val isSendEnabled: Boolean
get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
@@ -270,12 +275,22 @@ class PromptPanel(
editor.addDocumentListener(object : DocumentListener {
override fun documentChanged(e: DocumentEvent) {
invalidateEnhancement()
if (e.document.isInBulkUpdate) {
deferEditorSync()
syncButton()
onChange()
return
}
syncEditorHeight()
triggerCompletion(e)
syncHighlights()
syncButton()
onChange()
}
override fun bulkUpdateFinished(document: Document) {
deferEditorSync()
}
})
shell.add(strip, BorderLayout.NORTH)
shell.add(editor, BorderLayout.CENTER)
@@ -399,7 +414,7 @@ class PromptPanel(
@RequiresEdt
private fun chrome(ed: EditorEx) {
if (ed.isDisposed) return
style.applyPromptToEditor(ed)
style.applyPromptToEditor(ed, SessionUiStyle.View.Prompt.bgColor(style))
if (ed.isDisposed) return
}
@@ -485,11 +500,12 @@ class PromptPanel(
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
background = style.editorScheme.defaultBackground
shell.background = style.editorScheme.defaultBackground
val bg = SessionUiStyle.View.Prompt.bgColor(style)
background = bg
shell.background = bg
style.applyTranscriptToField(editor)
editor.getEditor(false)?.let(::chrome)
editor.background = style.editorBackground
editor.background = bg
syncEditorHeight()
syncAutoApprove()
syncHighlights()
@@ -937,6 +953,10 @@ class PromptPanel(
@RequiresEdt
private fun syncEditorHeight() {
if (editor.document.isInBulkUpdate) {
deferEditorSync()
return
}
val before = editor.preferredSize.height
val lower = editor.minimumSize.height
editor.setPreferredSize(null)
@@ -968,6 +988,19 @@ class PromptPanel(
repaint()
}
@RequiresEdt
private fun deferEditorSync() {
if (deferred) return
deferred = true
ApplicationManager.getApplication().invokeLater {
deferred = false
if (project.isDisposed || editor.document.isInBulkUpdate) return@invokeLater
syncEditorHeight()
syncHighlights()
syncButton()
}
}
@RequiresEdt
private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) {
// AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on
@@ -80,7 +80,7 @@ data class SessionEditorStyle(
}
/** Apply the visible prompt-input text styling to embedded session editor components. */
fun applyPromptToEditor(editor: EditorEx) {
fun applyPromptToEditor(editor: EditorEx, background: Color = editorBackground) {
if (editor.isDisposed) return
applyTranscriptToEditor(editor)
if (editor.isDisposed) return
@@ -92,11 +92,11 @@ data class SessionEditorStyle(
0,
JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET),
)
editor.backgroundColor = editorBackground
editor.component.background = editorBackground
editor.contentComponent.background = editorBackground
editor.scrollPane.background = editorBackground
editor.scrollPane.viewport.background = editorBackground
editor.backgroundColor = background
editor.component.background = background
editor.contentComponent.background = background
editor.scrollPane.background = background
editor.scrollPane.viewport.background = background
editor.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
editor.scrollPane.revalidate()
editor.scrollPane.repaint()
@@ -96,6 +96,16 @@ object SessionUiStyle {
/** Prompt input dimensions and chrome inside the session view. */
object Prompt {
/**
* Background of the prompt input and the transcript user-prompt bubble. Uses a dedicated
* theme key so the prompt surface can be restyled independently, defaulting to the
* code-fragment background so the prompt matches rendered code blocks.
*/
fun bgColor(style: SessionEditorStyle): Color = JBColor.namedColor(
"Kilo.Session.Prompt.Background",
UiStyle.Colors.codeBlockBackground(style.editorScheme),
)
const val EDITOR_LINES = 1
const val EDITOR_CHROME = 16
const val SEND_BUTTON_SIZE = 24
@@ -121,6 +131,8 @@ object SessionUiStyle {
const val CARD_HEIGHT = 59
const val CLOSE_SIZE = 18
const val CORNER_ARC = 8
const val CHIP_HEIGHT = 28
const val CHIP_ICON_GAP = 6
}
/** Full-session file drop overlay geometry and colors. */
@@ -6,12 +6,14 @@ import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.util.ui.JBUI
import java.awt.FlowLayout
import java.net.URI
import java.nio.file.Path
import javax.swing.JComponent
class AttachmentView(
private var item: FileAttachment,
@@ -48,20 +50,31 @@ class AttachmentView(
override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}"
private fun chip(item: FileAttachment) = AttachmentCard(
AttachmentCardItem(name(item), item.mime, item.url),
open = { openAttachment(item) },
)
private fun chip(item: FileAttachment): JComponent {
val card = AttachmentCardItem(name(item), item.mime, item.url)
if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) })
return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) })
}
private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename
private fun same(next: FileAttachment) = item.mime == next.mime &&
item.url == next.url &&
item.filename == next.filename &&
item.startLine == next.startLine &&
item.endLine == next.endLine
private fun file(item: FileAttachment): Boolean {
if (item.source?.path?.isNotBlank() == true) return true
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false
return uri.scheme == "file"
}
companion object {
fun openDefault(item: FileAttachment, openFile: SessionFileOpener, openUrl: (String) -> Unit) {
val url = item.url.takeIf { it.isNotBlank() } ?: return
val uri = runCatching { URI.create(url) }.getOrNull() ?: return
if (uri.scheme == "file") {
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return
openFile(path, null)
val path = runCatching { Path.of(clean(uri)).toString() }.getOrNull() ?: return
openFile(href(path, item), null)
return
}
if (SessionFileLinks.isFileHref(url)) {
@@ -70,6 +83,14 @@ class AttachmentView(
}
openUrl(url)
}
private fun href(path: String, item: FileAttachment): String {
val start = item.startLine ?: return path
val end = item.endLine ?: start
return "$path:$start-$end"
}
private fun clean(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null)
}
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
@@ -99,7 +99,7 @@ class MessageView(
init {
isOpaque = false
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = SessionUiStyle.View.Prompt.bgColor(style)
border = assistantBorder()
// Populate content that already exists (e.g. after loadHistory)
@@ -212,12 +212,11 @@ class MessageView(
}
}
val view = view(content)
val item = wrapPrompt(view)
view.resize = resize
view.hover = hover
view.applyStyle(style)
parts[content.id] = view
add(item)
wrapPrompt(view)?.let { add(it) }
}
@RequiresEdt
@@ -227,7 +226,9 @@ class MessageView(
it.hover = hover
it.applyStyle(style)
attachments = it
add(it)
val node = ensurePromptWrap()
promptBox?.add(it, BorderLayout.SOUTH)
if (node.parent == null) add(node)
}
view.upsert(content)
parts[content.id] = view
@@ -253,20 +254,23 @@ class MessageView(
@RequiresEdt
private fun replacePart(content: Content, existing: PartView) {
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
// A replaced tool view is a direct child, so re-insert at its own slot. Only fall back to
// the prompt wrap's index when the replaced view is nested inside it, otherwise the wrap's
// lower index would push the replacement above the prompt bubble on user messages.
val at = (if (existing.parent !== this) components.indexOf(wrap) else components.indexOfFirst { it === existing })
.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
aliases.values.removeAll { it == content.id }
sources.keys.removeAll { it !in aliases }
detach(existing)
remove(existing)
removeView(existing)
if (existing === prompt) prompt = null
Disposer.dispose(existing)
val view = view(content)
val item = wrapPrompt(view)
view.resize = resize
view.hover = hover
view.applyStyle(style)
parts[content.id] = view
add(item, at)
wrapPrompt(view)?.let { add(it, at) }
syncBorder()
refresh()
}
@@ -294,10 +298,11 @@ class MessageView(
}
aliases.values.removeAll { it == contentId }
sources.keys.removeAll { it !in aliases }
detach(view)
remove(view)
removeView(view)
Disposer.dispose(view)
if (view === prompt) prompt = null
syncBorder()
syncPromptWrap()
refresh()
return true
}
@@ -325,10 +330,10 @@ class MessageView(
@RequiresEdt
private fun rebuildParts() {
parts.values.distinct().forEach {
detach(it)
remove(it)
removeView(it)
Disposer.dispose(it)
}
wrap?.let { remove(it) }
parts.clear()
aliases.clear()
sources.clear()
@@ -434,7 +439,7 @@ class MessageView(
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = SessionUiStyle.View.Prompt.bgColor(style)
for (view in parts.values) view.applyStyle(style)
refresh()
}
@@ -442,10 +447,10 @@ class MessageView(
@RequiresEdt
override fun dispose() {
parts.values.forEach {
detach(it)
remove(it)
removeView(it)
Disposer.dispose(it)
}
wrap?.let { remove(it) }
parts.clear()
aliases.clear()
sources.clear()
@@ -476,14 +481,17 @@ class MessageView(
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
val pt = if (box === this) Point() else SwingUtilities.convertPoint(box, Point(), this)
val x = pt.x
val y = pt.y
val w = box.width - 1
val h = box.height - 1
g2.color = style.editorScheme.defaultBackground
g2.fillRoundRect(x, y, box.width, box.height, arc, arc)
g2.color = SessionUiStyle.View.Outline.color()
if (w > 0 && h > 0) g2.drawRoundRect(x, y, w, h, arc, arc)
val bg = SessionUiStyle.View.Prompt.bgColor(style)
g2.color = bg
g2.fillRoundRect(pt.x, pt.y, box.width, box.height, arc, arc)
// When the prompt shares the session background there is no fill contrast, so draw the
// outline to keep the bubble visible.
if (bg.rgb == style.editorBackground.rgb) {
val w = box.width - 1
val h = box.height - 1
g2.color = SessionUiStyle.View.Outline.color()
if (w > 0 && h > 0) g2.drawRoundRect(pt.x, pt.y, w, h, arc, arc)
}
} finally {
g2.dispose()
}
@@ -502,19 +510,42 @@ class MessageView(
}
@RequiresEdt
private fun wrapPrompt(view: PartView): JComponent {
private fun removeView(view: PartView) {
detach(view)
view.parent?.remove(view)
}
@RequiresEdt
private fun wrapPrompt(view: PartView): JComponent? {
if (role != SessionUiStyle.View.Message.USER_ROLE) return view
if (view !is PromptView) return view
prompt = view
val node = ensurePromptWrap()
val box = promptBox ?: return node
if (view.parent !== box) box.add(view, BorderLayout.CENTER)
node.bar.setActive(true)
return node.takeIf { it.parent == null }
}
@RequiresEdt
private fun ensurePromptWrap(): PromptWrap {
val existing = wrap
if (existing != null) return existing
val box = JPanel(BorderLayout()).also {
it.isOpaque = false
it.add(view, BorderLayout.CENTER)
promptBox = it
}
val node = PromptWrap(box)
wrap = node
node.bar.setActive(true)
return node
return PromptWrap(box).also { wrap = it }
}
@RequiresEdt
private fun syncPromptWrap() {
val node = wrap ?: return
val box = promptBox ?: return
if (box.componentCount > 0) return
node.parent?.remove(node)
wrap = null
promptBox = null
}
private inner class PromptWrap(
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
@@ -12,6 +13,8 @@ import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import java.net.URI
import javax.swing.JComponent
import javax.swing.ScrollPaneConstants
class PromptAttachmentView(
@@ -21,10 +24,12 @@ class PromptAttachmentView(
override val contentId: String = "attachments:$messageId"
private val items = LinkedHashMap<String, FileAttachment>()
private val cards = LinkedHashMap<String, AttachmentCard>()
private val cards = LinkedHashMap<String, JComponent>()
private val row = Stack.horizontal(gap = UiStyle.Gap.sm())
private val scroll = JBScrollPane(row).apply {
border = null
// Empty borders remove the visible scroll pane frame; null can be replaced by the current UI.
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
isOpaque = false
viewport.isOpaque = false
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
@@ -33,10 +38,11 @@ class PromptAttachmentView(
init {
isOpaque = false
// Align the attachment chips with the prompt text horizontally, with only a small bottom inset.
border = JBUI.Borders.empty(
0,
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
UiStyle.Gap.sm(),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
add(scroll)
@@ -83,7 +89,7 @@ class PromptAttachmentView(
override fun getPreferredSize(): Dimension {
val ins = insets
val pref = scroll.preferredSize
return Dimension(0, pref.height + bar() + ins.top + ins.bottom)
return Dimension(0, pref.height + ins.top + ins.bottom)
}
override fun getMinimumSize() = preferredSize
@@ -111,14 +117,23 @@ class PromptAttachmentView(
repaint()
}
private fun card(item: FileAttachment) = AttachmentCard(
AttachmentCardItem(name(item), item.mime, item.url),
open = { openAttachment(item) },
)
private fun card(item: FileAttachment): JComponent {
val card = AttachmentCardItem(name(item), item.mime, item.url)
if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) })
return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) })
}
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime &&
a.url == b.url &&
a.filename == b.filename &&
a.startLine == b.startLine &&
a.endLine == b.endLine
private fun bar() = scroll.horizontalScrollBar.preferredSize.height
private fun file(item: FileAttachment): Boolean {
if (item.source?.path?.isNotBlank() == true) return true
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false
return uri.scheme == "file"
}
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
?: tail(item.url).takeIf { it.isNotBlank() }
@@ -74,7 +74,7 @@ class PromptView(
override fun styleFont(style: SessionEditorStyle) = style.transcriptFont
override fun styleBackground(style: SessionEditorStyle) = style.editorBackground
override fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.View.Prompt.bgColor(style)
private fun sync() {
md.set(linkifyMentions(buffer.toString(), mentions))
@@ -7,6 +7,8 @@ import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.ui.fileLinkHtml
import ai.kilocode.client.session.ui.fileLinkText
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -135,8 +137,8 @@ class FileLinkLabel(
@RequiresEdt
fun setTarget(path: String?, text: String): Boolean {
val next = single(text.ifBlank { path.orEmpty() })
val value = if (next.isBlank()) "" else XmlStringUtil.wrapInHtml("<nobr><u>${XmlStringUtil.escapeString(next)}</u></nobr>")
val next = fileLinkText(text.ifBlank { path.orEmpty() })
val value = fileLinkHtml(next)
var changed = false
if (href != path) {
href = path
@@ -489,7 +491,7 @@ internal fun setText(label: JBLabel, text: String): Boolean {
@RequiresEdt
internal fun setTargetText(label: JBLabel, text: String): Boolean {
val value = single(text)
val value = fileLinkText(text)
if (label.text == value) return false
label.text = value
return true
@@ -511,16 +513,11 @@ private fun <T : JBLabel> clip(label: T): T = label.apply {
}
private fun html(text: String): String {
val value = single(text)
val value = fileLinkText(text)
if (value.isBlank()) return ""
return XmlStringUtil.wrapInHtml("<nobr>${XmlStringUtil.escapeString(value)}</nobr>")
}
private fun single(text: String): String = text.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(" ")
@RequiresEdt
internal fun show(parts: ToolParts, link: Boolean): Boolean {
var changed = false
@@ -1,5 +1,6 @@
package ai.kilocode.client.settings.context
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.rpc.dto.CompactionPatchDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto
@@ -9,6 +10,7 @@ internal data class ContextDraft(
val auto: Boolean = false,
val threshold: String = "",
val prune: Boolean = false,
val editor: Boolean = KiloPluginSettings.getAutoEditorContext(),
val ignore: List<String> = emptyList(),
)
@@ -21,6 +23,7 @@ internal fun contextDraft(config: ConfigDto?): ContextDraft = ContextDraft(
auto = config?.compaction?.auto ?: false,
threshold = config?.compaction?.threshold_percent?.let(::formatThreshold).orEmpty(),
prune = config?.compaction?.prune ?: false,
editor = KiloPluginSettings.getAutoEditorContext(),
ignore = config?.watcher?.ignore ?: emptyList(),
)
@@ -38,8 +41,11 @@ internal fun savedMatches(base: ContextDraft, draft: ContextDraft): Boolean =
base.auto == draft.auto &&
normalizeThreshold(base.threshold) == normalizeThreshold(draft.threshold) &&
base.prune == draft.prune &&
base.editor == draft.editor &&
base.ignore == draft.ignore
internal fun localChanged(base: ContextDraft, draft: ContextDraft): Boolean = base.editor != draft.editor
internal fun thresholdStatus(value: String): ThresholdStatus {
val text = value.trim()
if (text.isBlank()) return ThresholdStatus.VALID
@@ -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
@@ -62,10 +63,23 @@ internal class ContextSettingsUi(
startSettings(ContextSettingsContent { updateDraft(it) })
}
override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to)?.takeIf(::changed)
override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? {
val patch = patch(from, to) ?: return null
if (changed(patch)) return patch
return ConfigPatchDto().takeIf { localChanged(from, to) }
}
override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) {
app.updateConfigAsync(change, done)
val value = draft.editor
if (!changed(change)) {
KiloPluginSettings.setAutoEditorContext(value)
done(appState)
return
}
app.updateConfigAsync(change) { result ->
if (result != null) KiloPluginSettings.setAutoEditorContext(value)
done(result)
}
}
override fun base(result: KiloAppStateDto): ContextDraft = contextDraft(result.config)
@@ -134,6 +148,10 @@ internal class ContextSettingsContent(
private val update: (ContextDraft.() -> ContextDraft) -> Unit,
) : BaseContentPanel() {
private val auto = SettingsToggle { value -> update { copy(auto = value) } }
// Editor-context auto-include is a local per-IDE preference in PropertiesComponent (like
// autoApprove). It participates in this page's draft/apply/reset state so the Configurable
// Apply button reflects unsaved local changes, but it is never sent as CLI config.
private val editor = SettingsToggle { value -> update { copy(editor = value) } }
private val prune = SettingsToggle { value -> update { copy(prune = value) } }
private val threshold = ThresholdField(
KiloBundle.message("settings.context.compaction.threshold.placeholder"),
@@ -163,6 +181,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,6 +197,10 @@ internal class ContextSettingsContent(
@RequiresEdt
fun sync(draft: ContextDraft, enabled: Boolean) {
auto.isSelected = draft.auto
// Local preference: draft-driven, but still enabled regardless of the CLI-backed [enabled]
// gating that applies to the remote config rows below.
editor.isSelected = draft.editor
editor.isEnabled = true
prune.isSelected = draft.prune
threshold.sync(draft.threshold)
patterns.sync(draft.ignore)
@@ -2,6 +2,7 @@ package ai.kilocode.client.ui
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
@@ -11,6 +12,13 @@ import java.awt.event.MouseEvent
open class PickerButton : JBLabel() {
private var over = false
/**
* Idle (unhovered) fill. Defaults to the standard picker surface; set to `null` to paint
* nothing so the picker blends into its container (e.g. the prompt background). The hover
* fill is unaffected.
*/
var idleFill: Color? = UiStyle.Colors.picker()
init {
border = pickerBorder()
background = UiStyle.Colors.picker()
@@ -34,14 +42,17 @@ open class PickerButton : JBLabel() {
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else UiStyle.Colors.picker()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
val fill = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else idleFill
if (fill != null) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = fill
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
super.paintComponent(g)
}
@@ -1,6 +1,8 @@
package ai.kilocode.client.ui
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorColorsScheme
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
@@ -118,6 +120,14 @@ object UiStyle {
/** Uses the editor background so chat cards feel native beside editor content. */
fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground }
/**
* Background for rendered code fragments (markdown code blocks). Uses the editor's doc
* code-block attribute background and falls back to the editor background when the theme
* leaves it unset.
*/
fun codeBlockBackground(scheme: EditorColorsScheme): Color =
scheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK)?.backgroundColor ?: scheme.defaultBackground
/**
* Contained panel background: follows the active theme's text-field/input surface.
* Falls back to the panel background when unavailable.
@@ -94,7 +94,7 @@ internal object MdCommon {
?: fg(style, DefaultLanguageHighlighterColors.DOC_COMMENT)
?: UIUtil.getContextHelpForeground()
val border = color(style, EditorColors.PREVIEW_BORDER_COLOR) ?: UiStyle.Colors.contentBorder()
val blockBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorBackground
val blockBg = UiStyle.Colors.codeBlockBackground(style.editorScheme)
return MdStyle(
font = style.transcriptFont,
foreground = style.editorForeground,
@@ -227,6 +227,8 @@ session.attachment.unsupported=Cannot preview {0}
session.attachment.mime=Type: {0}
session.attachment.size=Size: {0} bytes
session.attachment.error=Failed to load attachment: {0}
session.attachment.file.range={0}:{1}-{2}
session.attachment.unknown=Attached content ({0})
prompt.action.enhance=Enhance prompt
prompt.action.enhance.loading=Enhancing prompt...
prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.
@@ -363,6 +365,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
@@ -41,7 +41,7 @@ class SessionFileLinksTest : BasePlatformTestCase() {
fun `test parse strips line range and column suffixes`() {
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12"))
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12-20"))
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, endLine = 20), SessionFileLinks.parse("src/Foo.kt:12-20"))
assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, column = 3), SessionFileLinks.parse("src/Foo.kt:12:3"))
}
@@ -96,6 +96,18 @@ class SessionFileLinksTest : BasePlatformTestCase() {
assertEquals("true", events.single().second["hasLine"])
}
fun `test open forwards line range to workspace service`() = runBlocking {
val file = WorkspaceFileDto("/test/src/Foo.kt", "Foo.kt")
val done = CompletableDeferred<Unit>()
rpc.fileResolver = { path -> if (path == "src/Foo.kt") listOf(file) else emptyList() }
val links = SessionFileLinks("/test", service, scope, JPanel(), openUrl = {}) { _, _ -> done.complete(Unit) }
links.open("src/Foo.kt:12-20", null)
withTimeout(OPEN_TIMEOUT_MS) { done.await() }
assertEquals(listOf(FakeWorkspaceRpcApi.Opened("/test/src/Foo.kt", 12, null, 20)), rpc.openedFiles)
}
private companion object {
const val OPEN_TIMEOUT_MS = 5_000L
}
@@ -498,6 +498,61 @@ class SessionScrollTest : SessionUiTestBase() {
assertFalse(jumpButton().isVisible)
}
fun `test turn close after modified files keeps pending tail follow`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
emit(ChatEventDto.TurnOpen("ses_test"))
drainScroll()
assertBottom(bar)
assertTrue(ui.scroll.following())
val id = "modified_close_tail"
val pid = "modified_close_part"
emit(ChatEventDto.MessageUpdated("ses_test", message(id).copy(summary = MessageSummaryDto(listOf(modifiedFile())))), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "tail line\n".repeat(160))), flush = false)
forceFlushWithoutDispatch()
emit(ChatEventDto.TurnClose("ses_test", "completed"))
drainScroll()
assertBottom(bar)
assertTrue(ui.scroll.following())
assertFalse(jumpButton().isVisible)
findAll<EditorTextField>(ui).first().text = "next prompt"
find<PromptPanel>(ui).send()
settleShort(100)
val text = rpc.prompts.last().third.parts.single().text
val next = "modified_close_next"
emit(ChatEventDto.MessageUpdated("ses_test", message(next)), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("modified_close_next_part", next, "text", text)), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test turn close after modified files preserves user scroll position`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
val value = bar.value
emit(ChatEventDto.TurnOpen("ses_test"), flush = false)
emit(ChatEventDto.MessageUpdated("ses_test", message("modified_close_middle").copy(summary = MessageSummaryDto(listOf(modifiedFile())))), flush = false)
emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = false)
forceFlush()
drainScroll()
assertEquals(value, bar.value)
assertFalse(ui.scroll.following())
assertTrue(jumpButton().isVisible)
}
fun `test prompt editor growth preserves middle scroll position`() {
showMessages()
fillTranscript(24)
@@ -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,34 @@
package ai.kilocode.client.session.context
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.vfs.VfsUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
class KiloIgnoreCacheTest : BasePlatformTestCase() {
fun `test matcher caches until ignore file changes`() {
val file = myFixture.addFileToProject(".kilocodeignore", "ignored/\n").virtualFile
val root = file.parent
val cache = project.service<KiloIgnoreCache>()
val first = cache.matcher(root)
assertTrue(first.ignored("ignored/Secret.kt"))
assertFalse(first.ignored("src/App.kt"))
assertSame(first, cache.matcher(root))
ApplicationManager.getApplication().runWriteAction {
VfsUtil.saveText(file, "src/\n")
}
UIUtil.dispatchAllInvocationEvents()
val second = cache.matcher(root)
assertNotSame(first, second)
assertFalse(second.ignored("ignored/Secret.kt"))
assertTrue(second.ignored("src/App.kt"))
}
fun `test null root allows everything`() {
assertSame(KiloIgnore.EMPTY, project.service<KiloIgnoreCache>().matcher(null))
}
}
@@ -0,0 +1,95 @@
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"))
}
fun `test malformed char class is skipped without throwing`() {
val ignore = KiloIgnore.of("[z-a]\n[]\n[!]\n*.log")
assertTrue(ignore.ignored("debug.log"))
assertFalse(ignore.ignored("src/App.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" })
}
}
@@ -70,6 +70,7 @@ import com.intellij.ui.components.JBLabel
import com.intellij.util.Producer
import com.intellij.util.ui.EmptyIcon
import com.intellij.ui.scale.JBUIScale
import com.intellij.util.DocumentUtil
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
@@ -137,11 +138,11 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals(style.transcriptFont.size, font.size)
}
fun `test prompt input uses editor background`() {
fun `test prompt input uses prompt background`() {
val style = SessionEditorStyle.current()
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background)
assertEquals(SessionUiStyle.View.Prompt.bgColor(style), panel.defaultFocusedComponent.background)
}
fun `test prompt editor hides floating toolbar`() {
@@ -248,7 +249,12 @@ class PromptPanelTest : BasePlatformTestCase() {
HighlighterColors.TEXT,
TextAttributes(Color(0xEA, 0xEA, 0xEA), bg, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_BLOCK,
TextAttributes(null, bg, null, null, Font.PLAIN),
)
val style = SessionEditorStyle.create(scheme = scheme)
val promptBg = SessionUiStyle.View.Prompt.bgColor(style)
realize(panel, 260, 400)
val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!!
@@ -258,11 +264,11 @@ class PromptPanelTest : BasePlatformTestCase() {
panel.applyStyle(style)
assertEquals(bg, panel.defaultFocusedComponent.background)
assertEquals(bg, editor.backgroundColor)
assertEquals(bg, editor.scrollPane.background)
assertEquals(bg, editor.scrollPane.viewport.background)
assertEquals(bg, editor.contentComponent.background)
assertEquals(promptBg, panel.defaultFocusedComponent.background)
assertEquals(promptBg, editor.backgroundColor)
assertEquals(promptBg, editor.scrollPane.background)
assertEquals(promptBg, editor.scrollPane.viewport.background)
assertEquals(promptBg, editor.contentComponent.background)
}
fun `test prompt editor grows when lines are added`() {
@@ -501,6 +507,22 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals("hello", editor.document.text)
}
fun `test prompt editor height sync skips bulk document updates`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
val editor = field.getEditor(false)!!
WriteCommandAction.runWriteCommandAction(project) {
DocumentUtil.executeInBulk(editor.document, true) {
editor.document.insertString(0, "hello")
}
}
UIUtil.dispatchAllInvocationEvents()
assertEquals("hello", editor.document.text)
}
fun `test prompt editor highlights missing mention as wrong reference`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.views.MessageToolbar
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.PromptAttachmentView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.TurnView
import ai.kilocode.client.session.views.base.PartView
@@ -809,7 +810,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
layout(message)
val box = promptBox(message)
val point = SwingUtilities.convertPoint(box, Point(), message)
assertTrue("prompt box should be below attachment", point.y > 0)
val attachment = components(message).filterIsInstance<PromptAttachmentView>().single()
val attachmentPoint = SwingUtilities.convertPoint(attachment, Point(), box)
assertTrue("attachment should be inside prompt box below prompt text", attachmentPoint.y > 0)
val image = BufferedImage(message.width, message.height, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
@@ -818,7 +821,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
val line = SessionUiStyle.View.Outline.color().rgb
assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb)
assertFalse(line == Color(image.getRGB(point.x + box.width / 2, 0), true).rgb)
assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y + box.height - 1), true).rgb)
}
fun `test created ContentDelta is not double applied`() {
@@ -1635,7 +1638,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
}
private fun promptBox(root: MessageView): Component {
return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView }
return components(root).first { it.parent != root && it is JPanel && it.components.any { child -> child is TextView } }
}
private fun components(root: Component): List<Component> {
@@ -3,7 +3,9 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.views.AttachmentView
import ai.kilocode.client.session.views.PromptAttachmentView
import ai.kilocode.client.session.views.tool.ReadToolView
@@ -20,6 +22,7 @@ import ai.kilocode.rpc.dto.PartSourceTextDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.Container
import java.awt.event.MouseEvent
@@ -270,16 +273,21 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
val attachment = msg.part("f1")!!
val other = msg.part("f2")!!
assertSame(msg, attachment.parent)
assertNotSame(msg, attachment.parent)
assertSame(attachment, other)
assertEquals(listOf("p1", "f1", "f2"), msg.partIds())
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
assertEquals(2, findAll(attachment, AttachmentCard::class.java).size)
assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size)
assertEquals(1, findAll(attachment, AttachmentCard::class.java).size)
assertEquals(1, findAll(attachment, AttachmentChip::class.java).size)
val cards = findAll(attachment, AttachmentCard::class.java)
for (card in cards) {
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
}
val chips = findAll(attachment, AttachmentChip::class.java)
for (chip in chips) {
chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
}
assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened)
}
@@ -356,7 +364,26 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
val view = panel.findMessage("u1")!!.part("f1")
assertTrue(view is PromptAttachmentView)
assertNotNull(find(view!!, AttachmentCard::class.java))
assertNotNull(find(view!!, AttachmentChip::class.java))
}
fun `test source less file selection renders filename range chip`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "file:///tmp/HvJwtFilter.java?start=12&end=40",
filename = "HvJwtFilter.java",
))
val view = panel.findMessage("u1")!!.part("f1")!!
val chip = find(view, AttachmentChip::class.java)
assertNotNull(chip)
assertTrue(findAll(chip!!, JBLabel::class.java).any { it.text.contains("<u>HvJwtFilter.java:12-40</u>") })
}
fun `test source backed image attachment still renders in prompt strip`() {
@@ -400,7 +427,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertNull(msg.part("p2"))
assertEquals(listOf("p1", "f1"), msg.partIds())
assertTrue(msg.part("p1") is TextView)
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size)
}
fun `test prompt text panel is removed when content becomes empty`() {
@@ -441,11 +468,9 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy)
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy)
assertEquals(0, view.insets.top)
assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), view.insets.bottom)
assertEquals(UiStyle.Gap.sm(), view.insets.bottom)
assertEquals(
JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) +
pane.horizontalScrollBar.preferredSize.height +
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) + UiStyle.Gap.sm(),
height,
)
@@ -497,8 +522,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
),
)
val card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!!
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
val chip = find(item.findMessage("u1")!!.part("f1")!!, AttachmentChip::class.java)!!
chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened)
}
@@ -0,0 +1,48 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import javax.swing.SwingUtilities
class MessageViewTest : BasePlatformTestCase() {
// A user message can carry both a prompt bubble (wrapped, lower component index) and a tool
// view added after it. Replacing that tool (e.g. a completed question) must reuse the tool's
// own slot, not the prompt wrap's lower index, or the replacement jumps above the bubble.
fun `test replacing a tool view keeps it below the prompt bubble`() {
val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0)))
val view = MessageView(msg, openFile = { _, _ -> })
val text = Text("p1").also { it.content.append("do the thing") }
msg.parts["p1"] = text
view.upsertPart(text)
val tool = Tool("t1", "question", ToolKind.GENERIC).also {
it.state = ToolExecState.RUNNING
it.input = mapOf("questions" to """[{"question":"Proceed?"}]""")
}
msg.parts["t1"] = tool
view.upsertPart(tool)
tool.state = ToolExecState.COMPLETED
tool.metadata = mapOf("answers" to """[["Yes"]]""")
view.upsertPart(tool)
val result = view.part("t1")
val prompt = view.part("p1")
assertNotNull(result)
assertNotNull(prompt)
assertTrue(result is QuestionResultView)
val children = view.components.toList()
val wrapIndex = children.indexOfFirst { SwingUtilities.isDescendingFrom(prompt, it) }
val resultIndex = children.indexOf(result)
assertTrue("prompt bubble is a direct child", wrapIndex >= 0)
assertTrue("question result stays below the prompt bubble", resultIndex > wrapIndex)
}
}
@@ -0,0 +1,72 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.attachment.AttachmentChip
import ai.kilocode.client.ui.UiStyle
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import java.awt.Container
class PromptAttachmentViewTest : BasePlatformTestCase() {
// The attachment strip should line up with the prompt text horizontally and keep only a
// small standard bottom inset below the selection reference.
fun `test attachment padding matches prompt text with small bottom inset`() {
val prompt = PromptView(Text("p1")).insets
val attach = PromptAttachmentView("m1") {}.insets
assertEquals(prompt.left, attach.left)
assertEquals(prompt.right, attach.right)
assertEquals(0, attach.top)
assertEquals(UiStyle.Gap.sm(), attach.bottom)
}
fun `test attachment scroll pane has no border line`() {
val scroll = PromptAttachmentView("m1") {}.scrollPane()
assertEquals(0, scroll.border.getBorderInsets(scroll).top)
assertEquals(0, scroll.border.getBorderInsets(scroll).left)
assertEquals(0, scroll.border.getBorderInsets(scroll).bottom)
assertEquals(0, scroll.border.getBorderInsets(scroll).right)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).top)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).left)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).bottom)
assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).right)
}
// With the outline removed, the chip owns no internal padding; alignment comes from the
// container so the chip content sits flush against the prompt-matching insets.
fun `test attachment chip has no outline padding`() {
val chip = AttachmentChip(
AttachmentCardItem("HvJwtFilter.java", "text/plain", "file:///HvJwtFilter.java"),
file = true,
startLine = 40,
endLine = 42,
).insets
assertEquals(0, chip.left)
assertEquals(0, chip.right)
assertEquals(0, chip.top)
assertEquals(0, chip.bottom)
}
fun `test attachment chip uses file link underline style`() {
val chip = AttachmentChip(
AttachmentCardItem("HvJwtFilter.java", "text/plain", "file:///HvJwtFilter.java"),
file = true,
startLine = 40,
endLine = 42,
)
val label = components(chip).filterIsInstance<JBLabel>().single()
assertTrue(label.text.contains("<u>HvJwtFilter.java:40-42</u>"))
}
private fun components(root: Container): List<java.awt.Component> = buildList {
fun visit(comp: java.awt.Component) {
add(comp)
if (comp is Container) comp.components.forEach { visit(it) }
}
visit(root)
}
}
@@ -229,14 +229,14 @@ class TextViewTest : BasePlatformTestCase() {
assertEquals(style.editorForeground, view.md.foreground)
}
fun `test prompt view uses transcript font and editor background`() {
fun `test prompt view uses transcript font and prompt background`() {
val style = SessionEditorStyle.create(family = "Courier New", size = 23)
val view = PromptView(Text("p1"))
view.applyStyle(style)
assertEquals(style.transcriptFont, view.md.font)
assertEquals(style.editorBackground, view.md.background)
assertEquals(SessionUiStyle.View.Prompt.bgColor(style), view.md.background)
assertFalse(view.contentOpaque())
}
@@ -3,15 +3,16 @@ package ai.kilocode.client.settings.context
import ai.kilocode.rpc.dto.CompactionConfigDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.WatcherConfigDto
import kotlin.test.Test
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ContextSettingsStateTest {
@Test
fun `draft reads context config`() {
// Extends BasePlatformTestCase so the IntelliJ Application is initialized: ContextDraft's default
// editor value reads a PropertiesComponent app service, which is null in a plain unit test.
class ContextSettingsStateTest : BasePlatformTestCase() {
fun `test draft reads context config`() {
val draft = contextDraft(ConfigDto(
watcher = WatcherConfigDto(ignore = listOf("**/dist/**")),
compaction = CompactionConfigDto(auto = true, threshold_percent = 75.0, prune = true),
@@ -23,15 +24,13 @@ class ContextSettingsStateTest {
assertEquals(listOf("**/dist/**"), draft.ignore)
}
@Test
fun `unchanged draft emits no patch`() {
fun `test unchanged draft emits no patch`() {
val draft = ContextDraft(auto = true, threshold = "75", prune = false, ignore = listOf("tmp/**"))
assertEquals(false, patch(draft, draft)?.let(::changed))
}
@Test
fun `boolean false values are emitted`() {
fun `test boolean false values are emitted`() {
val from = ContextDraft(auto = true, prune = true)
val to = ContextDraft(auto = false, prune = false)
val patch = patch(from, to)
@@ -40,8 +39,7 @@ class ContextSettingsStateTest {
assertEquals(false, patch?.compaction?.prune)
}
@Test
fun `threshold set and clear use explicit semantics`() {
fun `test threshold set and clear use explicit semantics`() {
val from = ContextDraft(threshold = "")
val set = ContextDraft(threshold = "80")
val clear = ContextDraft(threshold = "")
@@ -51,16 +49,14 @@ class ContextSettingsStateTest {
assertNull(patch(set, clear)?.compaction?.threshold_percent)
}
@Test
fun `watcher empty list is emitted`() {
fun `test watcher empty list is emitted`() {
val from = ContextDraft(ignore = listOf("**/dist/**"))
val to = ContextDraft(ignore = emptyList())
assertEquals(emptyList(), patch(from, to)?.watcher?.ignore)
assertEquals(emptyList<String>(), patch(from, to)?.watcher?.ignore)
}
@Test
fun `invalid threshold prevents patch without looking like no changes`() {
fun `test invalid threshold prevents patch without looking like no changes`() {
val from = ContextDraft(threshold = "50")
val to = ContextDraft(auto = true, threshold = "101", prune = true, ignore = listOf("tmp/**"))
@@ -68,8 +64,7 @@ class ContextSettingsStateTest {
assertNull(patch(from, to))
}
@Test
fun `saved match normalizes threshold formatting`() {
fun `test saved match normalizes threshold formatting`() {
assertTrue(savedMatches(ContextDraft(threshold = "75"), ContextDraft(threshold = "75.0")))
assertFalse(savedMatches(ContextDraft(threshold = "75"), ContextDraft(threshold = "76")))
}
@@ -2,6 +2,8 @@ package ai.kilocode.client.settings.context
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.settings.base.SettingsRow
import ai.kilocode.client.settings.base.SettingsToggle
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.testing.FakeAppRpcApi
@@ -69,6 +71,7 @@ class ContextSettingsUiTest : BasePlatformTestCase() {
ui = null
uiScope.cancel()
appScope.cancel()
KiloPluginSettings.unsetAutoEditorContext()
} finally {
super.tearDown()
}
@@ -253,21 +256,43 @@ class ContextSettingsUiTest : BasePlatformTestCase() {
}
}
fun `test controls are disabled during pending save`() {
fun `test controls are disabled during pending save except local editor toggle`() {
val panel = requireUi()
rpc.configUpdateGate = CompletableDeferred()
edt {
threshold(panel).text = "80"
panel.applyDraft()
assertTrue(components(panel).filterIsInstance<SettingsToggle>().all { !it.isEnabled })
val editor = editorToggle(panel)
val cli = components(panel).filterIsInstance<SettingsToggle>().filter { it !== editor }
assertTrue(cli.all { !it.isEnabled })
assertFalse(threshold(panel).isEnabled)
assertTrue(editor.isEnabled)
}
rpc.configUpdateGate?.complete(Unit)
flushUntil { rpc.configPatches.isNotEmpty() }
}
fun `test editor context toggle marks modified and applies without a config patch`() {
val panel = requireUi()
assertTrue(KiloPluginSettings.getAutoEditorContext())
edt {
val editor = editorToggle(panel)
assertTrue(editor.isEnabled)
editor.doClick()
assertTrue(panel.modified())
}
assertTrue(KiloPluginSettings.getAutoEditorContext())
edt { panel.applyDraft() }
assertFalse(KiloPluginSettings.getAutoEditorContext())
edt { UIUtil.dispatchAllInvocationEvents() }
assertFalse(edt { panel.modified() })
assertTrue(rpc.configPatches.isEmpty())
}
private fun requireUi(): ContextSettingsUi = requireNotNull(ui)
private fun threshold(panel: ContextSettingsUi): JBTextField = components(panel)
@@ -284,6 +309,14 @@ class ContextSettingsUiTest : BasePlatformTestCase() {
.filterIsInstance<HoverIcon>()
.single { it.toolTipText == tip }
private fun editorToggle(panel: ContextSettingsUi): SettingsToggle {
val label = components(panel).filterIsInstance<JLabel>()
.first { it.text == "Auto-Include Editor Context" }
var row: Container? = label.parent
while (row != null && row !is SettingsRow) row = row.parent
return components(requireNotNull(row)).filterIsInstance<SettingsToggle>().single()
}
private fun <T> edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
@@ -111,10 +111,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return branchName
}
override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean {
override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean {
assertNotEdt("openFile")
opened.add(path)
openedFiles.add(Opened(path, line, column))
openedFiles.add(Opened(path, line, column, endLine))
return openResult
}
@@ -150,5 +150,5 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
return openResult
}
data class Opened(val path: String, val line: Int?, val column: Int?)
data class Opened(val path: String, val line: Int?, val column: Int?, val endLine: Int? = null)
}
@@ -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 }
@@ -67,7 +67,7 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
suspend fun branchName(directory: String): String?
/** Open an absolute backend file path in the IDE. */
suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean
suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean
/** Resolve the editable local config target. */
suspend fun localConfigTarget(directory: String): ConfigTargetDto
@@ -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