diff --git a/.changeset/jetbrains-auto-editor-context.md b/.changeset/jetbrains-auto-editor-context.md
new file mode 100644
index 00000000000..3a6b91d758b
--- /dev/null
+++ b/.changeset/jetbrains-auto-editor-context.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/kilo-jetbrains": minor
+---
+
+Include the active editor file, open files, visible files, and selected text in JetBrains chat context by default, with a Context settings toggle to disable it. Files matched by `.kilocodeignore` (or `.gitignore` plus `.env` files) are excluded, and the default shell is reported to the agent.
diff --git a/.changeset/jetbrains-prompt-attachments.md b/.changeset/jetbrains-prompt-attachments.md
new file mode 100644
index 00000000000..ac1f1f117e2
--- /dev/null
+++ b/.changeset/jetbrains-prompt-attachments.md
@@ -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.
diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml
index af1ad2c5507..9e015ef7191 100644
--- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml
+++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml
index 8fd300cd6c4..fc04e21f7df 100644
--- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml
+++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml
index 5ab397fabf5..f709383cb2b 100644
--- a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml
+++ b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml
@@ -6,7 +6,7 @@
-
+
diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt
index 09e112dca15..85c08422b65 100644
--- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt
+++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt
@@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.CustomModelDto
import ai.kilocode.rpc.dto.CustomProviderConfigDto
import ai.kilocode.rpc.dto.CustomProviderSaveDto
import ai.kilocode.rpc.dto.DiffFileDto
+import ai.kilocode.rpc.dto.EditorContextDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageSummaryDto
@@ -822,10 +823,27 @@ object KiloCliDataParser {
if (variant != null) {
sb.append(""","variant":${escape(variant)}""")
}
+ val editor = prompt.editorContext
+ if (editor != null) {
+ sb.append(""","editorContext":${editorContextJson(editor)}""")
+ }
sb.append("}")
return sb.toString()
}
+ private fun editorContextJson(ctx: EditorContextDto): String {
+ val fields = mutableListOf()
+ ctx.directory?.let { fields += "\"directory\":${escape(it)}" }
+ ctx.worktree?.let { fields += "\"worktree\":${escape(it)}" }
+ ctx.visibleFiles?.takeIf { it.isNotEmpty() }?.let { fields += "\"visibleFiles\":${array(it)}" }
+ ctx.openTabs?.takeIf { it.isNotEmpty() }?.let { fields += "\"openTabs\":${array(it)}" }
+ ctx.activeFile?.let { fields += "\"activeFile\":${escape(it)}" }
+ ctx.shell?.let { fields += "\"shell\":${escape(it)}" }
+ return "{${fields.joinToString(",")}}"
+ }
+
+ private fun array(values: List): String = values.joinToString(",", "[", "]") { escape(it) }
+
private fun buildPromptPartJson(part: PromptPartDto): String {
val fields = mutableListOf("\"type\":${escape(part.type)}")
if (part.type == "file") {
diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt
index 8140b86bffd..14f9cbe12da 100644
--- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt
+++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt
@@ -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 {
diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt
index 23afe339091..06c0259d1a1 100644
--- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt
+++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt
@@ -2,6 +2,7 @@ package ai.kilocode.backend.cli
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.ChatEventDto
+import ai.kilocode.rpc.dto.EditorContextDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageErrorDto
import ai.kilocode.rpc.dto.MessageTimeDto
@@ -156,6 +157,27 @@ class ChatLogSummaryTest {
assertTrue(out.contains("variant=medium"), out)
}
+ @Test
+ fun `prompt dto summary includes editor context`() {
+ System.setProperty("kilo.dev.log.chat.content", "preview")
+
+ val out = ChatLogSummary.prompt(
+ PromptDto(
+ parts = listOf(PromptPartDto(type = "text", text = "hello")),
+ editorContext = EditorContextDto(
+ activeFile = "settings.gradle",
+ openTabs = listOf("settings.gradle", "src/App.kt"),
+ visibleFiles = listOf("settings.gradle"),
+ ),
+ )
+ )
+
+ assertTrue(out.contains("editorContext=true"), out)
+ assertTrue(out.contains("activeFile=\"settings.gradle\""), out)
+ assertTrue(out.contains("openTabs=2"), out)
+ assertTrue(out.contains("visibleFiles=1"), out)
+ }
+
@Test
fun `prompt dto summary redacts file attachment urls`() {
System.setProperty("kilo.dev.log.chat.content", "preview")
diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt
index dab1d5b7138..132149fbfd7 100644
--- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt
+++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt
@@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.CompactionPatchDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
+import ai.kilocode.rpc.dto.EditorContextDto
import ai.kilocode.rpc.dto.McpConfigDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
@@ -1958,6 +1959,25 @@ class KiloCliDataParserTest {
assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result)
}
+ @Test
+ fun `buildPromptJson - with editor context`() {
+ val prompt = PromptDto(
+ parts = listOf(PromptPartDto("text", "Hi")),
+ editorContext = EditorContextDto(
+ activeFile = "src/App.kt",
+ visibleFiles = listOf("src/App.kt"),
+ openTabs = listOf("src/App.kt", "src/Other.kt"),
+ ),
+ )
+
+ val result = KiloCliDataParser.buildPromptJson(prompt)
+
+ assertEquals(
+ """{"parts":[{"type":"text","text":"Hi"}],"editorContext":{"visibleFiles":["src/App.kt"],"openTabs":["src/App.kt","src/Other.kt"],"activeFile":"src/App.kt"}}""",
+ result,
+ )
+ }
+
@Test
fun `buildProviderOAuthJson - numeric method index`() {
val result = KiloCliDataParser.buildProviderOAuthJson("0", mapOf("deploymentType" to "github.com"))
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt
index 66c3625bd6e..33d628d2a2e 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt
@@ -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
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt
index 16641aab323..1a234402861 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt
@@ -4,6 +4,7 @@ import com.intellij.ide.util.PropertiesComponent
object KiloPluginSettings {
private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove"
+ private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext"
private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded"
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
@@ -16,6 +17,16 @@ object KiloPluginSettings {
PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY)
}
+ fun getAutoEditorContext(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_EDITOR_CONTEXT_KEY, true)
+
+ fun setAutoEditorContext(value: Boolean) {
+ PropertiesComponent.getInstance().setValue(AUTO_EDITOR_CONTEXT_KEY, value.toString())
+ }
+
+ internal fun unsetAutoEditorContext() {
+ PropertiesComponent.getInstance().unsetValue(AUTO_EDITOR_CONTEXT_KEY)
+ }
+
fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false)
fun setPermissionRulesExpanded(value: Boolean) {
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt
index daee52fea8f..88f3804098c 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt
@@ -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(),
)
}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
index a7b30a796c8..28c4cd1750d 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt
@@ -47,6 +47,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
+import ai.kilocode.client.session.context.EditorContextGatherer
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.permission.PermissionView
@@ -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) {
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)}"
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt
new file mode 100644
index 00000000000..ecf163c35fa
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt
@@ -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().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? {
+ val set = open.toHashSet()
+ return EditorHistoryManager.getInstance(project).fileList.lastOrNull { it in set }
+ }
+
+ // Walks up from an open editor file to the workspace-root directory so ignore
+ // files can be read via the same (possibly remote) VFS as the editor files.
+ private fun rootDir(files: List, root: Path): VirtualFile? {
+ for (file in files) {
+ var cur: VirtualFile? = file
+ while (cur != null) {
+ if (runCatching { Path.of(cur.path).toAbsolutePath().normalize() }.getOrNull() == root) return cur
+ cur = cur.parent
+ }
+ }
+ return null
+ }
+
+ private fun selection(editor: Editor, root: Path, ignore: KiloIgnore): PromptPartDto? {
+ val model = editor.selectionModel
+ if (!model.hasSelection()) return null
+ val file = file(editor) ?: return null
+ val path = local(file, root) ?: return null
+ if (ignore.ignored(root.relativize(path).toString())) return null
+ val start = model.selectionStart
+ val end = model.selectionEnd
+ if (start == end) return null
+ val doc = editor.document
+ val last = (end - 1).coerceAtLeast(start)
+ val first = doc.getLineNumber(start) + 1
+ val line = doc.getLineNumber(last) + 1
+ val url = "${path.toUri()}?start=$first&end=$line"
+ return PromptPartDto(
+ type = "file",
+ mime = "text/plain",
+ url = url,
+ filename = path.name,
+ )
+ }
+
+ private fun file(editor: Editor): VirtualFile? = FileDocumentManager.getInstance().getFile(editor.document)
+
+ private fun rel(file: VirtualFile?, root: Path): String? {
+ val path = file?.let { local(it, root) } ?: return null
+ return root.relativize(path).toString()
+ }
+
+ private fun local(file: VirtualFile, root: Path): Path? {
+ if (file.fileSystem.protocol == KiloVirtualFileSystem.PROTOCOL) return null
+ // 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
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt
new file mode 100644
index 00000000000..dedc4666f5a
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt
@@ -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) {
+
+ /** 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()
+ read(root, GIT)?.takeIf { it.isNotBlank() }?.let { rules += compile(it) }
+ rules += SENSITIVE.mapNotNull { rule(it) }
+ return KiloIgnore(rules)
+ }
+
+ /** Test seam: build a matcher directly from ignore-file text. */
+ fun of(text: String): KiloIgnore = KiloIgnore(compile(text))
+
+ private fun read(root: VirtualFile, name: String): String? {
+ val file = root.findChild(name) ?: return null
+ if (!file.isValid || file.isDirectory) return null
+ return runCatching { VfsUtilCore.loadText(file) }.getOrNull()
+ }
+
+ private fun compile(text: String): List = text.lineSequence().mapNotNull { rule(it) }.toList()
+
+ private fun rule(raw: String): Rule? {
+ var line = raw.trimEnd()
+ if (line.isEmpty() || line.startsWith("#")) return null
+ val negate = line.startsWith("!")
+ if (negate) line = line.substring(1)
+ val dirOnly = line.endsWith("/")
+ if (dirOnly) line = line.trimEnd('/')
+ val leading = line.startsWith("/")
+ if (leading) line = line.trimStart('/')
+ if (line.isEmpty()) return null
+ val anchored = leading || line.contains('/')
+ val prefix = if (anchored) "" else "(?:.*/)?"
+ val suffix = if (dirOnly) "/.*" else "(?:/.*)?"
+ // 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()
+ }
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt
new file mode 100644
index 00000000000..170b144cb81
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt
@@ -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()
+
+ init {
+ ApplicationManager.getApplication().messageBus.connect(this)
+ .subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
+ override fun after(events: List) {
+ 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
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt
index a0f0013b98a..0ae42b857bc 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt
@@ -31,6 +31,7 @@ import ai.kilocode.client.util.UiTimers
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
+import ai.kilocode.rpc.dto.EditorContextDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
@@ -261,11 +262,11 @@ class SessionController(
}
}
- fun prompt(text: String, files: List = emptyList()) {
+ fun prompt(text: String, files: List = emptyList(), editorContext: EditorContextDto? = null) {
assertEdt()
val start = sid ?: ref?.key ?: "pending"
val exists = sid != null
- val dto = promptDto(text, files)
+ val dto = promptDto(text, files, editorContext)
val props = promptProps(files)
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id ->
@@ -1820,7 +1821,11 @@ class SessionController(
}
}
- private fun promptDto(text: String, files: List = emptyList()): PromptDto {
+ private fun promptDto(
+ text: String,
+ files: List = emptyList(),
+ editorContext: EditorContextDto? = null,
+ ): PromptDto {
val full = model.model
val sel = full?.let(::parseModel)
val variant = model.variant?.takeIf { it in model.variants }
@@ -1834,6 +1839,7 @@ class SessionController(
modelID = sel?.second,
agent = model.agent,
variant = variant,
+ editorContext = editorContext,
)
}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt
index beee188bd70..225b88c6bbe 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt
@@ -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. */
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt
index 1d918c70bcd..53878cd75f1 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt
@@ -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
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt
new file mode 100644
index 00000000000..bcc402ef85e
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt
@@ -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("${XmlStringUtil.escapeString(text)}")
+}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt
index 2ae5cdc7596..b5b3d3ebc3b 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt
@@ -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,
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt
index 9efa44aabf1..f4d850bdc68 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt
@@ -23,6 +23,9 @@ class PromptAttachmentStrip(
private val chips = LinkedHashMap()
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
}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt
index dbea7374493..7a4fcd32974 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt
@@ -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
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt
index 1a71b24c65a..66d4c18fef0 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt
@@ -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()
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt
index 82cdcd40321..9a3af597cf7 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt
@@ -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. */
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt
index 95de4237000..7f21a7acf25 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt
@@ -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() }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt
index c4ae7dc75b8..710c0f93351 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt
@@ -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(
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt
index a01148a25fc..331499655e3 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt
@@ -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()
- private val cards = LinkedHashMap()
+ private val cards = LinkedHashMap()
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() }
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt
index 7d3f7e267e9..32ddf7c21ec 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt
@@ -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))
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt
index a17b164e31d..3eabce5da94 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt
@@ -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("${XmlStringUtil.escapeString(next)}")
+ 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 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("${XmlStringUtil.escapeString(value)}")
}
-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
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt
index 3a5989a3325..6de6c44133d 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt
@@ -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 = 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
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt
index 7a66f2ac59a..8b156065b82 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt
@@ -3,6 +3,7 @@ package ai.kilocode.client.settings.context
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
+import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.settings.base.BaseContentPanel
import ai.kilocode.client.settings.base.BaseSettingsUi
import ai.kilocode.client.settings.base.SettingsBannerKind
@@ -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)
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt
index 86b1b6763ef..f2ccbdd8564 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt
@@ -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)
}
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt
index 7e711204661..b874ed74910 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt
@@ -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.
diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt
index ac73c1400a5..d95db32ee09 100644
--- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt
+++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt
@@ -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,
diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
index 074f5942042..2c1c665a0ad 100644
--- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
+++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
@@ -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
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt
index 13c56f27f06..1294933f4a8 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt
@@ -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()
+ 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
}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt
index 70d9ffeec8f..f9685fd2c53 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt
@@ -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(ui).first().text = "next prompt"
+ find(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)
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt
new file mode 100644
index 00000000000..2e7fd549490
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt
@@ -0,0 +1,91 @@
+package ai.kilocode.client.session.context
+
+import ai.kilocode.client.plugin.KiloPluginSettings
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.util.SystemInfo
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.intellij.util.EnvironmentUtil
+import com.intellij.util.ui.UIUtil
+
+class EditorContextGathererTest : BasePlatformTestCase() {
+ override fun tearDown() {
+ try {
+ KiloPluginSettings.unsetAutoEditorContext()
+ } finally {
+ super.tearDown()
+ }
+ }
+
+ fun `test gather includes active open visible files and selected range`() {
+ val psi = myFixture.addFileToProject(
+ "src/App.kt",
+ "fun main() {\n println(\"hi\")\n println(\"bye\")\n}\n",
+ )
+ val manager = FileEditorManager.getInstance(project)
+ manager.openFile(psi.virtualFile, true)
+ UIUtil.dispatchAllInvocationEvents()
+ val editor = manager.selectedTextEditor!!
+ val doc = editor.document
+ editor.selectionModel.setSelection(doc.getLineStartOffset(1), doc.getLineEndOffset(2))
+ val root = psi.virtualFile.parent.parent.path
+
+ val result = EditorContextGatherer.gather(project, root)
+
+ assertEquals("src/App.kt", result.context?.activeFile)
+ assertEquals(listOf("src/App.kt"), result.context?.openTabs)
+ assertEquals(listOf("src/App.kt"), result.context?.visibleFiles)
+ assertEquals("text/plain", result.selection?.mime)
+ assertEquals("App.kt", result.selection?.filename)
+ assertTrue(result.selection?.url, result.selection?.url.orEmpty().contains("/src/App.kt?start=2&end=3"))
+ val expectedShell = if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL")
+ assertEquals(expectedShell, result.context?.shell)
+ }
+
+ fun `test gather filters kilocodeignore files from open tabs`() {
+ val app = myFixture.addFileToProject("src/App.kt", "fun main() {}")
+ val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1")
+ myFixture.addFileToProject(".kilocodeignore", "ignored/\n")
+ val manager = FileEditorManager.getInstance(project)
+ manager.openFile(secret.virtualFile, true)
+ manager.openFile(app.virtualFile, true)
+ UIUtil.dispatchAllInvocationEvents()
+ val root = app.virtualFile.parent.parent.path
+
+ val result = EditorContextGatherer.gather(project, root)
+
+ assertEquals("src/App.kt", result.context?.activeFile)
+ assertEquals(listOf("src/App.kt"), result.context?.openTabs)
+ assertEquals(listOf("src/App.kt"), result.context?.visibleFiles)
+ }
+
+ fun `test gather drops selection when active file is ignored`() {
+ val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1\nval other = 2\n")
+ myFixture.addFileToProject(".kilocodeignore", "ignored/\n")
+ val manager = FileEditorManager.getInstance(project)
+ manager.openFile(secret.virtualFile, true)
+ UIUtil.dispatchAllInvocationEvents()
+ val editor = manager.selectedTextEditor!!
+ val doc = editor.document
+ editor.selectionModel.setSelection(doc.getLineStartOffset(0), doc.getLineEndOffset(0))
+ val root = secret.virtualFile.parent.parent.path
+
+ val result = EditorContextGatherer.gather(project, root)
+
+ assertNull(result.context?.activeFile)
+ assertNull(result.context?.openTabs)
+ assertNull(result.context?.visibleFiles)
+ assertNull(result.selection)
+ }
+
+ fun `test gather returns empty when setting is off`() {
+ KiloPluginSettings.setAutoEditorContext(false)
+ val psi = myFixture.addFileToProject("src/App.kt", "fun main() {}")
+ FileEditorManager.getInstance(project).openFile(psi.virtualFile, true)
+ UIUtil.dispatchAllInvocationEvents()
+
+ val result = EditorContextGatherer.gather(project, psi.virtualFile.parent.parent.path)
+
+ assertNull(result.context)
+ assertNull(result.selection)
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt
new file mode 100644
index 00000000000..aca6828d293
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt
@@ -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()
+
+ 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().matcher(null))
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt
new file mode 100644
index 00000000000..1dedb3b4aec
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt
@@ -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"))
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt
new file mode 100644
index 00000000000..53cd3e1b05f
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt
@@ -0,0 +1,30 @@
+package ai.kilocode.client.session.controller
+
+import ai.kilocode.rpc.dto.EditorContextDto
+import ai.kilocode.rpc.dto.PromptPartDto
+import kotlin.test.assertEquals
+
+class EditorContextPromptTest : SessionControllerTestBase() {
+ fun `test prompt forwards editor context`() {
+ val (c, _, _) = prompted()
+ rpc.prompts.clear()
+ val ctx = EditorContextDto(
+ activeFile = "src/App.kt",
+ openTabs = listOf("src/App.kt"),
+ visibleFiles = listOf("src/App.kt"),
+ )
+ val file = PromptPartDto(
+ type = "file",
+ mime = "text/plain",
+ url = "file:///test/src/App.kt?start=2&end=3",
+ filename = "App.kt",
+ )
+
+ edt { c.prompt("explain", listOf(file), ctx) }
+ flush()
+
+ val prompt = rpc.prompts.single().third
+ assertEquals(ctx, prompt.editorContext)
+ assertEquals(file, prompt.parts.first { it.type == "file" })
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt
index cb787b2b77d..3f9c0e22fa5 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt
@@ -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
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt
index 95ff55bf948..8f369199893 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt
@@ -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().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 {
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt
index dee8f91c9ff..e85eb69e4cf 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt
@@ -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().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("HvJwtFilter.java:12-40") })
}
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().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)
}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt
new file mode 100644
index 00000000000..cf1e3408db8
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt
@@ -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)
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt
new file mode 100644
index 00000000000..27ad1470af7
--- /dev/null
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt
@@ -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().single()
+
+ assertTrue(label.text.contains("HvJwtFilter.java:40-42"))
+ }
+
+ private fun components(root: Container): List = buildList {
+ fun visit(comp: java.awt.Component) {
+ add(comp)
+ if (comp is Container) comp.components.forEach { visit(it) }
+ }
+ visit(root)
+ }
+}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt
index 14bfaea5269..4f6d118dfec 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt
@@ -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())
}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt
index 8fc6dd6179d..d7c285627fa 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt
@@ -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(), 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")))
}
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt
index 80c5a2e1c4d..44337dd149a 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt
@@ -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().all { !it.isEnabled })
+ val editor = editorToggle(panel)
+ val cli = components(panel).filterIsInstance().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()
.single { it.toolTipText == tip }
+ private fun editorToggle(panel: ContextSettingsUi): SettingsToggle {
+ val label = components(panel).filterIsInstance()
+ .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().single()
+ }
+
private fun edt(block: () -> T): T {
var result: T? = null
ApplicationManager.getApplication().invokeAndWait { result = block() }
diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt
index 327566bf9c3..da98aa58b5a 100644
--- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt
+++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt
@@ -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)
}
diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt
index 588c0c9d0e6..c6e1f3cc34e 100644
--- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt
+++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt
@@ -72,10 +72,22 @@ object ChatLogSummary {
prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" }
model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" }
prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" }
+ prompt.editorContext?.let { ctx ->
+ out += "editorContext=true"
+ ctx.activeFile?.let { file -> out += editorFile("activeFile", file) }
+ ctx.openTabs?.size?.takeIf { it > 0 }?.let { out += "openTabs=$it" }
+ ctx.visibleFiles?.size?.takeIf { it > 0 }?.let { out += "visibleFiles=$it" }
+ ctx.shell?.takeIf { it.isNotBlank() }?.let { out += "shell=$it" }
+ }
preview(text)?.let { out += "preview=\"$it\"" }
return out.joinToString(" ")
}
+ private fun editorFile(key: String, file: String): String {
+ if (mode() == Mode.OFF) return "${key}Hash=${hash(file)}"
+ return "$key=\"${clean(file)}\""
+ }
+
fun history(items: List): String {
val out = mutableListOf()
val parts = items.sumOf { it.parts.size }
diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt
index f609a796da1..bebd2c38690 100644
--- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt
+++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt
@@ -67,7 +67,7 @@ interface KiloWorkspaceRpcApi : RemoteApi {
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
diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt
index ecf0da6a410..5e21bfdd98c 100644
--- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt
+++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt
@@ -122,6 +122,17 @@ data class PromptDto(
val agent: String? = null,
val variant: String? = null,
val noReply: Boolean? = null,
+ val editorContext: EditorContextDto? = null,
+)
+
+@Serializable
+data class EditorContextDto(
+ val directory: String? = null,
+ val worktree: String? = null,
+ val visibleFiles: List? = null,
+ val openTabs: List? = null,
+ val activeFile: String? = null,
+ val shell: String? = null,
)
@Serializable