From 62923adb518371d1659ea65e5519768e4abf231b Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 7 Aug 2026 15:26:38 -0400 Subject: [PATCH 01/10] feat(jetbrains): add automatic editor context Include active editor files, open tabs, visible files, selected ranges, and shell metadata in JetBrains chat prompts so agents can infer the user's current editor context without manual attachment. Filter paths through .kilocodeignore or fallback gitignore/.env rules before sending them to the backend. --- .changeset/jetbrains-auto-editor-context.md | 5 + .../.run/Run IDE (Backend).run.xml | 2 +- .../.run/Run IDE (Frontend).run.xml | 2 +- .../.run/runIdeSplitMode.run.xml | 2 +- .../kilocode/backend/cli/KiloCliDataParser.kt | 18 +++ .../backend/cli/ChatLogSummaryTest.kt | 22 +++ .../backend/cli/KiloCliDataParserTest.kt | 20 +++ .../client/plugin/KiloPluginSettings.kt | 11 ++ .../ai/kilocode/client/session/SessionUi.kt | 9 +- .../session/context/EditorContextGatherer.kt | 136 +++++++++++++++++ .../client/session/context/KiloIgnore.kt | 138 ++++++++++++++++++ .../session/controller/SessionController.kt | 12 +- .../settings/context/ContextSettingsUi.kt | 14 +- .../resources/messages/KiloBundle.properties | 3 + .../context/EditorContextGathererTest.kt | 91 ++++++++++++ .../client/session/context/KiloIgnoreTest.kt | 89 +++++++++++ .../controller/EditorContextPromptTest.kt | 30 ++++ .../kotlin/ai/kilocode/log/ChatLogSummary.kt | 12 ++ .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 11 ++ 19 files changed, 617 insertions(+), 10 deletions(-) create mode 100644 .changeset/jetbrains-auto-editor-context.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt diff --git a/.changeset/jetbrains-auto-editor-context.md b/.changeset/jetbrains-auto-editor-context.md new file mode 100644 index 00000000000..3a6b91d758b --- /dev/null +++ b/.changeset/jetbrains-auto-editor-context.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Include the active editor file, open files, visible files, and selected text in JetBrains chat context by default, with a Context settings toggle to disable it. Files matched by `.kilocodeignore` (or `.gitignore` plus `.env` files) are excluded, and the default shell is reported to the agent. diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index af1ad2c5507..9e015ef7191 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -5,7 +5,7 @@ diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml index 8fd300cd6c4..fc04e21f7df 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -5,7 +5,7 @@ diff --git a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml index 5ab397fabf5..f709383cb2b 100644 --- a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml +++ b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml @@ -6,7 +6,7 @@ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 09e112dca15..85c08422b65 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageSummaryDto @@ -822,10 +823,27 @@ object KiloCliDataParser { if (variant != null) { sb.append(""","variant":${escape(variant)}""") } + val editor = prompt.editorContext + if (editor != null) { + sb.append(""","editorContext":${editorContextJson(editor)}""") + } sb.append("}") return sb.toString() } + private fun editorContextJson(ctx: EditorContextDto): String { + val fields = mutableListOf() + ctx.directory?.let { fields += "\"directory\":${escape(it)}" } + ctx.worktree?.let { fields += "\"worktree\":${escape(it)}" } + ctx.visibleFiles?.takeIf { it.isNotEmpty() }?.let { fields += "\"visibleFiles\":${array(it)}" } + ctx.openTabs?.takeIf { it.isNotEmpty() }?.let { fields += "\"openTabs\":${array(it)}" } + ctx.activeFile?.let { fields += "\"activeFile\":${escape(it)}" } + ctx.shell?.let { fields += "\"shell\":${escape(it)}" } + return "{${fields.joinToString(",")}}" + } + + private fun array(values: List): String = values.joinToString(",", "[", "]") { escape(it) } + private fun buildPromptPartJson(part: PromptPartDto): String { val fields = mutableListOf("\"type\":${escape(part.type)}") if (part.type == "file") { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt index 23afe339091..06c0259d1a1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.cli import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -156,6 +157,27 @@ class ChatLogSummaryTest { assertTrue(out.contains("variant=medium"), out) } + @Test + fun `prompt dto summary includes editor context`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + + val out = ChatLogSummary.prompt( + PromptDto( + parts = listOf(PromptPartDto(type = "text", text = "hello")), + editorContext = EditorContextDto( + activeFile = "settings.gradle", + openTabs = listOf("settings.gradle", "src/App.kt"), + visibleFiles = listOf("settings.gradle"), + ), + ) + ) + + assertTrue(out.contains("editorContext=true"), out) + assertTrue(out.contains("activeFile=\"settings.gradle\""), out) + assertTrue(out.contains("openTabs=2"), out) + assertTrue(out.contains("visibleFiles=1"), out) + } + @Test fun `prompt dto summary redacts file attachment urls`() { System.setProperty("kilo.dev.log.chat.content", "preview") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index dab1d5b7138..132149fbfd7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto @@ -1958,6 +1959,25 @@ class KiloCliDataParserTest { assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result) } + @Test + fun `buildPromptJson - with editor context`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + editorContext = EditorContextDto( + activeFile = "src/App.kt", + visibleFiles = listOf("src/App.kt"), + openTabs = listOf("src/App.kt", "src/Other.kt"), + ), + ) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"text","text":"Hi"}],"editorContext":{"visibleFiles":["src/App.kt"],"openTabs":["src/App.kt","src/Other.kt"],"activeFile":"src/App.kt"}}""", + result, + ) + } + @Test fun `buildProviderOAuthJson - numeric method index`() { val result = KiloCliDataParser.buildProviderOAuthJson("0", mapOf("deploymentType" to "github.com")) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index 16641aab323..1a234402861 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -4,6 +4,7 @@ import com.intellij.ide.util.PropertiesComponent object KiloPluginSettings { private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove" + private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) @@ -16,6 +17,16 @@ object KiloPluginSettings { PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY) } + fun getAutoEditorContext(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_EDITOR_CONTEXT_KEY, true) + + fun setAutoEditorContext(value: Boolean) { + PropertiesComponent.getInstance().setValue(AUTO_EDITOR_CONTEXT_KEY, value.toString()) + } + + internal fun unsetAutoEditorContext() { + PropertiesComponent.getInstance().unsetValue(AUTO_EDITOR_CONTEXT_KEY) + } + fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false) fun setPermissionRulesExpanded(value: Boolean) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index a7b30a796c8..7635c282e9c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -47,6 +47,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.context.EditorContextGatherer import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.permission.PermissionView @@ -682,14 +683,16 @@ class SessionUi( private fun sendPrompt(text: String, files: List) { if (text.isBlank() && files.isEmpty()) return + val editor = EditorContextGatherer.gather(project, workspace.directory) + val allFiles = files + listOfNotNull(editor.selection) val parts = buildList { text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } - addAll(files) + addAll(allFiles) } LOG.debug { val agent = controller.model.agent ?: "none" val model = controller.model.model ?: "none" - "${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}" + "${ChatLogSummary.prompt(PromptDto(parts = parts, editorContext = editor.context))} agent=$agent model=$model ready=${controller.ready}" } prompt.clear() val follow = scroll.atBottom() @@ -705,7 +708,7 @@ class SessionUi( scroll.followBottom(follow) return } - controller.prompt(text, files) + controller.prompt(text, allFiles, editor.context) scroll.followBottom(follow) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt new file mode 100644 index 00000000000..747a663c21d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt @@ -0,0 +1,136 @@ +package ai.kilocode.client.session.context + +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.EditorContextDto +import ai.kilocode.rpc.dto.PromptPartDto +import com.intellij.codeWithMe.ClientId +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.impl.EditorHistoryManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.EnvironmentUtil +import java.nio.file.Path +import kotlin.io.path.name + +/** + * Reads the active/open editors and current selection at prompt-send time. + * + * Split mode caveat: the chat UI runs under a non-local [ClientId], so + * [FileEditorManager.getOpenFiles]/[FileEditorManager.getSelectedTextEditor] take + * the per-client branch and return nothing. The `*WithRemotes` variants read the + * local composites directly and are the ones that actually see the user's tabs. + * These APIs are `@ApiStatus.Experimental`. + */ +internal object EditorContextGatherer { + private val LOG = KiloLog.create(EditorContextGatherer::class.java) + + // Resolved once per process; the login shell does not change during a session. + private val shell: String? by lazy { + if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL") + } + + data class Result( + val context: EditorContextDto?, + val selection: PromptPartDto?, + ) + + fun gather(project: Project, root: String): Result { + if (!KiloPluginSettings.getAutoEditorContext()) { + LOG.debug { "kind=editor-context enabled=false" } + return Result(null, null) + } + val manager = FileEditorManager.getInstance(project) + val base = Path.of(root).toAbsolutePath().normalize() + val openFiles = manager.openFilesWithRemotes + val editor = manager.selectedTextEditorWithRemotes.firstOrNull() + val activeFile = editor?.let { file(it) } ?: lastOpen(project, openFiles) ?: openFiles.firstOrNull() + val ignore = KiloIgnore.load(rootDir(listOfNotNull(activeFile) + openFiles, base)) + + fun keep(file: VirtualFile?): String? = rel(file, base)?.takeUnless { ignore.ignored(it) } + + val active = keep(activeFile) + val openRel = openFiles.mapNotNull { rel(it, base) }.distinct() + val open = openRel.filterNot { ignore.ignored(it) }.take(20) + val visible = (listOfNotNull(activeFile) + manager.selectedTextEditorWithRemotes.mapNotNull { file(it) }) + .mapNotNull { keep(it) } + .distinct() + .take(200) + val ctx = EditorContextDto( + activeFile = active, + openTabs = open.takeIf { it.isNotEmpty() }, + visibleFiles = visible.takeIf { it.isNotEmpty() }, + shell = shell, + ).takeIf { active != null || open.isNotEmpty() || visible.isNotEmpty() || shell != null } + val part = editor?.let { selection(it, base, ignore) } + LOG.debug { + val first = openFiles.firstOrNull() + val filtered = openRel.count { ignore.ignored(it) } + "kind=editor-context enabled=true localId=${ClientId.isCurrentlyUnderLocalId}" + + " rawOpen=${openFiles.size} rawSel=${manager.selectedTextEditorWithRemotes.size}" + + " active=${active ?: "none"} open=${open.size} visible=${visible.size} selection=${part != null}" + + " ignored=$filtered shell=${shell ?: "none"}" + + " firstFs=${first?.fileSystem?.protocol ?: "none"} firstLocal=${first?.isInLocalFileSystem ?: false}" + + " firstPath=${first?.path ?: "none"}" + } + return Result(ctx, part) + } + + private fun lastOpen(project: Project, open: List): VirtualFile? { + val set = open.toHashSet() + return EditorHistoryManager.getInstance(project).fileList.lastOrNull { it in set } + } + + // Walks up from an open editor file to the workspace-root directory so ignore + // files can be read via the same (possibly remote) VFS as the editor files. + private fun rootDir(files: List, root: Path): VirtualFile? { + for (file in files) { + var cur: VirtualFile? = file + while (cur != null) { + if (runCatching { Path.of(cur.path).toAbsolutePath().normalize() }.getOrNull() == root) return cur + cur = cur.parent + } + } + return null + } + + private fun selection(editor: Editor, root: Path, ignore: KiloIgnore): PromptPartDto? { + val model = editor.selectionModel + if (!model.hasSelection()) return null + val file = file(editor) ?: return null + val path = local(file, root) ?: return null + if (ignore.ignored(root.relativize(path).toString())) return null + val start = model.selectionStart + val end = model.selectionEnd + if (start == end) return null + val doc = editor.document + val last = (end - 1).coerceAtLeast(start) + val first = doc.getLineNumber(start) + 1 + val line = doc.getLineNumber(last) + 1 + val url = "${path.toUri()}?start=$first&end=$line" + return PromptPartDto( + type = "file", + mime = "text/plain", + url = url, + filename = path.name, + ) + } + + private fun file(editor: Editor): VirtualFile? = FileDocumentManager.getInstance().getFile(editor.document) + + private fun rel(file: VirtualFile?, root: Path): String? { + val path = file?.let { local(it, root) } ?: return null + return root.relativize(path).toString() + } + + private fun local(file: VirtualFile, root: Path): Path? { + if (file.fileSystem.protocol == KiloVirtualFileSystem.PROTOCOL) return null + val path = Path.of(file.path).toAbsolutePath().normalize() + if (!path.startsWith(root)) return null + return path + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt new file mode 100644 index 00000000000..bef689e556b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt @@ -0,0 +1,138 @@ +package ai.kilocode.client.session.context + +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile + +/** + * Minimal `.gitignore`-style matcher used to keep ignored or sensitive files out + * of the editor context sent to the model. + * + * Mirrors the VS Code `FileIgnoreController` precedence: + * - if `.kilocodeignore` exists and is non-empty, use only its patterns (plus the + * `.kilocodeignore` file itself); + * - otherwise fall back to `.gitignore` plus the sensitive `.env` / `.env.*` + * patterns. + * + * Paths are matched as workspace-relative POSIX paths. Only the subset of gitignore + * syntax relevant to path filtering is supported: comments (`#`), blank lines, + * negation (`!`), anchoring (leading or embedded `/`), directory-only (trailing + * `/`), and the `*`, `**`, `?`, and `[..]` globs. + */ +internal class KiloIgnore private constructor(private val rules: List) { + + /** True when [path] (workspace-relative) should be excluded from editor context. */ + fun ignored(path: String): Boolean { + val norm = path.replace('\\', '/').trim('/') + if (norm.isEmpty()) return false + var hit = false + for (rule in rules) { + if (rule.regex.matches(norm)) hit = !rule.negate + } + return hit + } + + private class Rule(val regex: Regex, val negate: Boolean) + + companion object { + val EMPTY = KiloIgnore(emptyList()) + + private const val KILO = ".kilocodeignore" + private const val GIT = ".gitignore" + private val SENSITIVE = listOf(".env", ".env.*") + + /** + * Builds the matcher from the ignore files under [root]. Reads through the VFS + * so it works in remote/split mode where the workspace lives on the host. + * Returns [EMPTY] (allow-all) when [root] is null or unreadable; the backend + * permission layer still guards file contents. + */ + fun load(root: VirtualFile?): KiloIgnore { + if (root == null) return EMPTY + val kilo = read(root, KILO) + if (!kilo.isNullOrBlank()) return KiloIgnore(compile(kilo) + compile(KILO)) + val rules = mutableListOf() + read(root, GIT)?.takeIf { it.isNotBlank() }?.let { rules += compile(it) } + rules += SENSITIVE.mapNotNull { rule(it) } + return KiloIgnore(rules) + } + + /** Test seam: build a matcher directly from ignore-file text. */ + fun of(text: String): KiloIgnore = KiloIgnore(compile(text)) + + private fun read(root: VirtualFile, name: String): String? { + val file = root.findChild(name) ?: return null + if (!file.isValid || file.isDirectory) return null + return runCatching { VfsUtilCore.loadText(file) }.getOrNull() + } + + private fun compile(text: String): List = text.lineSequence().mapNotNull { rule(it) }.toList() + + private fun rule(raw: String): Rule? { + var line = raw.trimEnd() + if (line.isEmpty() || line.startsWith("#")) return null + val negate = line.startsWith("!") + if (negate) line = line.substring(1) + val dirOnly = line.endsWith("/") + if (dirOnly) line = line.trimEnd('/') + val leading = line.startsWith("/") + if (leading) line = line.trimStart('/') + if (line.isEmpty()) return null + val anchored = leading || line.contains('/') + val prefix = if (anchored) "" else "(?:.*/)?" + val suffix = if (dirOnly) "/.*" else "(?:/.*)?" + return Rule(Regex("^$prefix${glob(line)}$suffix$"), negate) + } + + private fun glob(glob: String): String { + val sb = StringBuilder() + var i = 0 + while (i < glob.length) { + val c = glob[i] + when (c) { + '\\' -> { + val next = glob.getOrNull(i + 1) + if (next == null) sb.append("\\\\") + else { + if (!next.isLetterOrDigit()) sb.append('\\') + sb.append(next) + i++ + } + } + + '*' -> { + if (glob.getOrNull(i + 1) == '*') { + i++ + if (glob.getOrNull(i + 1) == '/') { + sb.append("(?:.*/)?") + i++ + } else { + sb.append(".*") + } + } else { + sb.append("[^/]*") + } + } + + '?' -> sb.append("[^/]") + + '[' -> { + val end = glob.indexOf(']', i + 1) + if (end == -1) { + sb.append("\\[") + } else { + val body = glob.substring(i + 1, end) + sb.append('[').append(if (body.startsWith("!")) "^${body.substring(1)}" else body).append(']') + i = end + } + } + + '.', '(', ')', '+', '|', '^', '$', '{', '}', ']' -> sb.append('\\').append(c) + + else -> sb.append(c) + } + i++ + } + return sb.toString() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a0f0013b98a..0ae42b857bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -31,6 +31,7 @@ import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -261,11 +262,11 @@ class SessionController( } } - fun prompt(text: String, files: List = emptyList()) { + fun prompt(text: String, files: List = emptyList(), editorContext: EditorContextDto? = null) { assertEdt() val start = sid ?: ref?.key ?: "pending" val exists = sid != null - val dto = promptDto(text, files) + val dto = promptDto(text, files, editorContext) val props = promptProps(files) LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" } dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id -> @@ -1820,7 +1821,11 @@ class SessionController( } } - private fun promptDto(text: String, files: List = emptyList()): PromptDto { + private fun promptDto( + text: String, + files: List = emptyList(), + editorContext: EditorContextDto? = null, + ): PromptDto { val full = model.model val sel = full?.let(::parseModel) val variant = model.variant?.takeIf { it in model.variants } @@ -1834,6 +1839,7 @@ class SessionController( modelID = sel?.second, agent = model.agent, variant = variant, + editorContext = editorContext, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt index 7a66f2ac59a..0118542137d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.context import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.settings.base.BaseContentPanel import ai.kilocode.client.settings.base.BaseSettingsUi import ai.kilocode.client.settings.base.SettingsBannerKind @@ -134,6 +135,9 @@ internal class ContextSettingsContent( private val update: (ContextDraft.() -> ContextDraft) -> Unit, ) : BaseContentPanel() { private val auto = SettingsToggle { value -> update { copy(auto = value) } } + private val editor = SettingsToggle(KiloPluginSettings.getAutoEditorContext()) { value -> + KiloPluginSettings.setAutoEditorContext(value) + } private val prune = SettingsToggle { value -> update { copy(prune = value) } } private val threshold = ThresholdField( KiloBundle.message("settings.context.compaction.threshold.placeholder"), @@ -163,6 +167,13 @@ internal class ContextSettingsContent( prune, )) } + section( + KiloBundle.message("settings.context.editor.title"), + ).row(SettingsRow( + KiloBundle.message("settings.context.editor.auto.title"), + KiloBundle.message("settings.context.editor.auto.description"), + editor, + )) section( KiloBundle.message("settings.context.watcher.title"), KiloBundle.message("settings.context.watcher.description"), @@ -172,10 +183,11 @@ internal class ContextSettingsContent( @RequiresEdt fun sync(draft: ContextDraft, enabled: Boolean) { auto.isSelected = draft.auto + editor.isSelected = KiloPluginSettings.getAutoEditorContext() prune.isSelected = draft.prune threshold.sync(draft.threshold) patterns.sync(draft.ignore) - listOf(auto, prune, threshold, patterns).forEach { it.isEnabled = enabled } + listOf(auto, editor, prune, threshold, patterns).forEach { it.isEnabled = enabled } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 074f5942042..f03000a254e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -363,6 +363,9 @@ settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Enter a number from 0 to 100, or leave the field blank. settings.context.compaction.prune.title=Prune Old Outputs settings.context.compaction.prune.description=Remove old tool outputs during compaction +settings.context.editor.title=Editor Context +settings.context.editor.auto.title=Auto-Include Editor Context +settings.context.editor.auto.description=Include the active file, open files, visible files, and selected text when sending chat messages. settings.context.watcher.title=File Watcher Ignore Patterns settings.context.watcher.description=Glob patterns for files the watcher should ignore settings.context.watcher.add=Add pattern diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt new file mode 100644 index 00000000000..2e7fd549490 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.context + +import ai.kilocode.client.plugin.KiloPluginSettings +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.SystemInfo +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.EnvironmentUtil +import com.intellij.util.ui.UIUtil + +class EditorContextGathererTest : BasePlatformTestCase() { + override fun tearDown() { + try { + KiloPluginSettings.unsetAutoEditorContext() + } finally { + super.tearDown() + } + } + + fun `test gather includes active open visible files and selected range`() { + val psi = myFixture.addFileToProject( + "src/App.kt", + "fun main() {\n println(\"hi\")\n println(\"bye\")\n}\n", + ) + val manager = FileEditorManager.getInstance(project) + manager.openFile(psi.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val editor = manager.selectedTextEditor!! + val doc = editor.document + editor.selectionModel.setSelection(doc.getLineStartOffset(1), doc.getLineEndOffset(2)) + val root = psi.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertEquals("src/App.kt", result.context?.activeFile) + assertEquals(listOf("src/App.kt"), result.context?.openTabs) + assertEquals(listOf("src/App.kt"), result.context?.visibleFiles) + assertEquals("text/plain", result.selection?.mime) + assertEquals("App.kt", result.selection?.filename) + assertTrue(result.selection?.url, result.selection?.url.orEmpty().contains("/src/App.kt?start=2&end=3")) + val expectedShell = if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL") + assertEquals(expectedShell, result.context?.shell) + } + + fun `test gather filters kilocodeignore files from open tabs`() { + val app = myFixture.addFileToProject("src/App.kt", "fun main() {}") + val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1") + myFixture.addFileToProject(".kilocodeignore", "ignored/\n") + val manager = FileEditorManager.getInstance(project) + manager.openFile(secret.virtualFile, true) + manager.openFile(app.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val root = app.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertEquals("src/App.kt", result.context?.activeFile) + assertEquals(listOf("src/App.kt"), result.context?.openTabs) + assertEquals(listOf("src/App.kt"), result.context?.visibleFiles) + } + + fun `test gather drops selection when active file is ignored`() { + val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1\nval other = 2\n") + myFixture.addFileToProject(".kilocodeignore", "ignored/\n") + val manager = FileEditorManager.getInstance(project) + manager.openFile(secret.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val editor = manager.selectedTextEditor!! + val doc = editor.document + editor.selectionModel.setSelection(doc.getLineStartOffset(0), doc.getLineEndOffset(0)) + val root = secret.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertNull(result.context?.activeFile) + assertNull(result.context?.openTabs) + assertNull(result.context?.visibleFiles) + assertNull(result.selection) + } + + fun `test gather returns empty when setting is off`() { + KiloPluginSettings.setAutoEditorContext(false) + val psi = myFixture.addFileToProject("src/App.kt", "fun main() {}") + FileEditorManager.getInstance(project).openFile(psi.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + + val result = EditorContextGatherer.gather(project, psi.virtualFile.parent.parent.path) + + assertNull(result.context) + assertNull(result.selection) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt new file mode 100644 index 00000000000..fbe71fa5a89 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.context + +import junit.framework.TestCase + +class KiloIgnoreTest : TestCase() { + fun `test empty allows everything`() { + val ignore = KiloIgnore.of("") + assertFalse(ignore.ignored("src/App.kt")) + assertFalse(ignore.ignored(".env")) + } + + fun `test basename matches at any depth`() { + val ignore = KiloIgnore.of("foo") + assertTrue(ignore.ignored("foo")) + assertTrue(ignore.ignored("a/b/foo")) + assertTrue(ignore.ignored("foo/child.txt")) + assertFalse(ignore.ignored("a/foobar")) + } + + fun `test extension glob`() { + val ignore = KiloIgnore.of("*.log") + assertTrue(ignore.ignored("a.log")) + assertTrue(ignore.ignored("nested/dir/a.log")) + assertFalse(ignore.ignored("a.log.kt")) + } + + fun `test directory only pattern matches contents`() { + val ignore = KiloIgnore.of("node_modules/") + assertTrue(ignore.ignored("node_modules/pkg/index.js")) + assertTrue(ignore.ignored("a/node_modules/pkg.js")) + assertFalse(ignore.ignored("node_modules")) + } + + fun `test leading slash anchors to root`() { + val ignore = KiloIgnore.of("/build") + assertTrue(ignore.ignored("build/out.js")) + assertFalse(ignore.ignored("src/build/out.js")) + } + + fun `test middle slash anchors to root`() { + val ignore = KiloIgnore.of("src/generated") + assertTrue(ignore.ignored("src/generated/A.kt")) + assertFalse(ignore.ignored("app/src/generated/A.kt")) + } + + fun `test double star matches across directories`() { + val ignore = KiloIgnore.of("**/dist") + assertTrue(ignore.ignored("dist/a.js")) + assertTrue(ignore.ignored("a/b/dist/a.js")) + + val nested = KiloIgnore.of("src/**/*.tmp") + assertTrue(nested.ignored("src/a/b/c.tmp")) + assertTrue(nested.ignored("src/x.tmp")) + assertFalse(nested.ignored("lib/a.tmp")) + } + + fun `test negation re-includes`() { + val ignore = KiloIgnore.of("*.log\n!keep.log") + assertTrue(ignore.ignored("debug.log")) + assertFalse(ignore.ignored("keep.log")) + } + + fun `test comments and blank lines ignored`() { + val ignore = KiloIgnore.of("# a comment\n\n*.secret\n") + assertTrue(ignore.ignored("api.secret")) + assertFalse(ignore.ignored("# a comment")) + } + + fun `test sensitive env patterns`() { + val ignore = KiloIgnore.of(".env\n.env.*") + assertTrue(ignore.ignored(".env")) + assertTrue(ignore.ignored(".env.local")) + assertTrue(ignore.ignored("cfg/.env.production")) + assertFalse(ignore.ignored("env")) + assertFalse(ignore.ignored("environment.ts")) + } + + fun `test char class`() { + val ignore = KiloIgnore.of("*.[oa]") + assertTrue(ignore.ignored("main.o")) + assertTrue(ignore.ignored("lib.a")) + assertFalse(ignore.ignored("main.c")) + } + + fun `test backslash separators normalized`() { + val ignore = KiloIgnore.of("node_modules/") + assertTrue(ignore.ignored("a\\node_modules\\pkg.js")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt new file mode 100644 index 00000000000..53cd3e1b05f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt @@ -0,0 +1,30 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.EditorContextDto +import ai.kilocode.rpc.dto.PromptPartDto +import kotlin.test.assertEquals + +class EditorContextPromptTest : SessionControllerTestBase() { + fun `test prompt forwards editor context`() { + val (c, _, _) = prompted() + rpc.prompts.clear() + val ctx = EditorContextDto( + activeFile = "src/App.kt", + openTabs = listOf("src/App.kt"), + visibleFiles = listOf("src/App.kt"), + ) + val file = PromptPartDto( + type = "file", + mime = "text/plain", + url = "file:///test/src/App.kt?start=2&end=3", + filename = "App.kt", + ) + + edt { c.prompt("explain", listOf(file), ctx) } + flush() + + val prompt = rpc.prompts.single().third + assertEquals(ctx, prompt.editorContext) + assertEquals(file, prompt.parts.first { it.type == "file" }) + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 588c0c9d0e6..c6e1f3cc34e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -72,10 +72,22 @@ object ChatLogSummary { prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" } model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" } prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" } + prompt.editorContext?.let { ctx -> + out += "editorContext=true" + ctx.activeFile?.let { file -> out += editorFile("activeFile", file) } + ctx.openTabs?.size?.takeIf { it > 0 }?.let { out += "openTabs=$it" } + ctx.visibleFiles?.size?.takeIf { it > 0 }?.let { out += "visibleFiles=$it" } + ctx.shell?.takeIf { it.isNotBlank() }?.let { out += "shell=$it" } + } preview(text)?.let { out += "preview=\"$it\"" } return out.joinToString(" ") } + private fun editorFile(key: String, file: String): String { + if (mode() == Mode.OFF) return "${key}Hash=${hash(file)}" + return "$key=\"${clean(file)}\"" + } + fun history(items: List): String { val out = mutableListOf() val parts = items.sumOf { it.parts.size } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index ecf0da6a410..5e21bfdd98c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -122,6 +122,17 @@ data class PromptDto( val agent: String? = null, val variant: String? = null, val noReply: Boolean? = null, + val editorContext: EditorContextDto? = null, +) + +@Serializable +data class EditorContextDto( + val directory: String? = null, + val worktree: String? = null, + val visibleFiles: List? = null, + val openTabs: List? = null, + val activeFile: String? = null, + val shell: String? = null, ) @Serializable From 74470aa8611cdb48e3dc6c2e0deaa027b9af46f9 Mon Sep 17 00:00:00 2001 From: kirillk Date: Fri, 7 Aug 2026 17:54:40 -0400 Subject: [PATCH 02/10] fix(jetbrains): render prompt attachments as chips --- .changeset/jetbrains-prompt-attachments.md | 5 ++ .../backend/rpc/KiloWorkspaceRpcApiImpl.kt | 26 ++++++- .../client/app/KiloWorkspaceService.kt | 8 +-- .../client/session/SessionFileLinks.kt | 11 +-- .../ai/kilocode/client/session/SessionUi.kt | 15 +++- .../kilocode/client/session/model/Message.kt | 2 + .../client/session/model/SessionModel.kt | 20 ++++++ .../session/ui/attachment/AttachmentCard.kt | 72 +++++++++++++++++++ .../client/session/ui/style/SessionUiStyle.kt | 3 + .../client/session/views/AttachmentView.kt | 35 +++++++-- .../client/session/views/MessageView.kt | 64 +++++++++++------ .../session/views/PromptAttachmentView.kt | 30 +++++--- .../resources/messages/KiloBundle.properties | 2 + .../client/session/SessionFileLinksTest.kt | 14 +++- .../session/ui/SessionMessageListPanelTest.kt | 9 ++- .../client/session/ui/SessionUiUpdateTest.kt | 40 +++++++++-- .../client/testing/FakeWorkspaceRpcApi.kt | 6 +- .../ai/kilocode/rpc/KiloWorkspaceRpcApi.kt | 2 +- 18 files changed, 299 insertions(+), 65 deletions(-) create mode 100644 .changeset/jetbrains-prompt-attachments.md 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/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/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/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 7635c282e9c..4c7df90e8a8 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 @@ -921,12 +921,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)}") @@ -937,6 +938,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/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/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt index 2ae5cdc7596..f433862985a 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 @@ -47,6 +47,78 @@ 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 + border = JBUI.Borders.empty(0, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HORIZONTAL_PADDING)) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = tip + accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name) + val label = JBLabel(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 + + override fun paintComponent(g: Graphics) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC) + g2.color = SessionUiStyle.View.Surface.bgColor() + g2.fillRoundRect(0, 0, width, height, arc, arc) + g2.color = SessionUiStyle.View.Outline.color() + g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) + } finally { + g2.dispose() + } + super.paintComponent(g) + } + + 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/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 82cdcd40321..aa8ce97a96f 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 @@ -121,6 +121,9 @@ 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_HORIZONTAL_PADDING = 8 + 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..3aa839f8059 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 @@ -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,19 @@ class MessageView( @RequiresEdt private fun replacePart(content: Content, existing: PartView) { - val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount + val at = components.indexOfFirst { it === existing || it === wrap }.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 +294,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 +326,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() @@ -442,10 +443,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() @@ -502,19 +503,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..fb15e0e1d22 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,7 +24,7 @@ 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 @@ -35,9 +38,9 @@ class PromptAttachmentView( isOpaque = false border = JBUI.Borders.empty( 0, - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + 0, JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + 0, ) add(scroll) } @@ -111,12 +114,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 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 bar() = scroll.horizontalScrollBar.preferredSize.height 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 f03000a254e..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. 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/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..188ef09d37f 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,6 +3,7 @@ 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.session.views.AttachmentView import ai.kilocode.client.session.views.PromptAttachmentView @@ -20,6 +21,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 +272,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 +363,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 == "HvJwtFilter.java:12-40" }) } fun `test source backed image attachment still renders in prompt strip`() { @@ -400,7 +426,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`() { @@ -497,8 +523,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/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/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 From f66b7efc6c2f0192205fdc2b9f4019850e47cd50 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 9 Aug 2026 15:27:19 -0400 Subject: [PATCH 03/10] fix(jetbrains): harden editor context and fix tool-view placement Address review feedback on the automatic editor-context PR: - Skip malformed .kilocodeignore/.gitignore globs instead of throwing PatternSyntaxException that broke every prompt send. - Add KiloIgnoreCache so the compiled matcher is cached per root and invalidated via a VFS listener, keeping the blocking (remote cwm) read off the prompt-send path after the first prompt. - Gather editor context only on the prompt path so slash commands and client actions no longer pay its cost or hit its failure modes. - Guard Path.of(file.path) against cross-OS invalid filenames, matching rootDir(). - Keep the local Auto-Include Editor Context toggle interactive regardless of backend readiness; it is a per-IDE preference, not CLI config. - Fix a replaced tool view (e.g. completed question) jumping above the prompt bubble on user messages. --- .../ai/kilocode/client/session/SessionUi.kt | 24 +++++----- .../session/context/EditorContextGatherer.kt | 7 ++- .../client/session/context/KiloIgnore.kt | 9 ++-- .../client/session/context/KiloIgnoreCache.kt | 46 ++++++++++++++++++ .../client/session/views/MessageView.kt | 6 ++- .../settings/context/ContextSettingsUi.kt | 8 +++- .../session/context/KiloIgnoreCacheTest.kt | 34 +++++++++++++ .../client/session/context/KiloIgnoreTest.kt | 6 +++ .../client/session/views/MessageViewTest.kt | 48 +++++++++++++++++++ .../settings/context/ContextSettingsUiTest.kt | 33 ++++++++++++- 10 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt 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 4c7df90e8a8..5068db342e2 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 @@ -683,17 +683,6 @@ class SessionUi( private fun sendPrompt(text: String, files: List) { if (text.isBlank() && files.isEmpty()) return - val editor = EditorContextGatherer.gather(project, workspace.directory) - val allFiles = files + listOfNotNull(editor.selection) - val parts = buildList { - text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } - addAll(allFiles) - } - LOG.debug { - 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}" - } prompt.clear() val follow = scroll.atBottom() val action = completion.clientAction(text) @@ -708,6 +697,19 @@ class SessionUi( scroll.followBottom(follow) return } + // 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) } 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 index 747a663c21d..ecf163c35fa 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -49,7 +50,7 @@ internal object EditorContextGatherer { val openFiles = manager.openFilesWithRemotes val editor = manager.selectedTextEditorWithRemotes.firstOrNull() val activeFile = editor?.let { file(it) } ?: lastOpen(project, openFiles) ?: openFiles.firstOrNull() - val ignore = KiloIgnore.load(rootDir(listOfNotNull(activeFile) + openFiles, base)) + val ignore = project.service().matcher(rootDir(listOfNotNull(activeFile) + openFiles, base)) fun keep(file: VirtualFile?): String? = rel(file, base)?.takeUnless { ignore.ignored(it) } @@ -129,7 +130,9 @@ internal object EditorContextGatherer { private fun local(file: VirtualFile, root: Path): Path? { if (file.fileSystem.protocol == KiloVirtualFileSystem.PROTOCOL) return null - val path = Path.of(file.path).toAbsolutePath().normalize() + // 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 index bef689e556b..dedc4666f5a 100644 --- 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 @@ -36,8 +36,8 @@ internal class KiloIgnore private constructor(private val rules: List) { companion object { val EMPTY = KiloIgnore(emptyList()) - private const val KILO = ".kilocodeignore" - private const val GIT = ".gitignore" + const val KILO = ".kilocodeignore" + const val GIT = ".gitignore" private val SENSITIVE = listOf(".env", ".env.*") /** @@ -80,7 +80,10 @@ internal class KiloIgnore private constructor(private val rules: List) { val anchored = leading || line.contains('/') val prefix = if (anchored) "" else "(?:.*/)?" val suffix = if (dirOnly) "/.*" else "(?:/.*)?" - return Rule(Regex("^$prefix${glob(line)}$suffix$"), negate) + // 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 { 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/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 3aa839f8059..7a55bc0d08a 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 @@ -254,7 +254,11 @@ class MessageView( @RequiresEdt private fun replacePart(content: Content, existing: PartView) { - val at = components.indexOfFirst { it === existing || it === wrap }.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 } 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 0118542137d..d6e4a8a4333 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 @@ -135,6 +135,9 @@ 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), applied immediately on toggle rather than through the CLI-backed draft/apply/ + // reset flow used by the other rows. It stays interactive even when the backend isn't READY. private val editor = SettingsToggle(KiloPluginSettings.getAutoEditorContext()) { value -> KiloPluginSettings.setAutoEditorContext(value) } @@ -183,11 +186,14 @@ internal class ContextSettingsContent( @RequiresEdt fun sync(draft: ContextDraft, enabled: Boolean) { auto.isSelected = draft.auto + // Local preference: reflects PropertiesComponent and stays enabled regardless of the + // CLI-backed [enabled] gating that applies to the draft-driven rows below. editor.isSelected = KiloPluginSettings.getAutoEditorContext() + editor.isEnabled = true prune.isSelected = draft.prune threshold.sync(draft.threshold) patterns.sync(draft.ignore) - listOf(auto, editor, prune, threshold, patterns).forEach { it.isEnabled = enabled } + listOf(auto, prune, threshold, patterns).forEach { it.isEnabled = enabled } } } 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 index fbe71fa5a89..1dedb3b4aec 100644 --- 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 @@ -86,4 +86,10 @@ class KiloIgnoreTest : TestCase() { 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/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/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt index 80c5a2e1c4d..ac29c470bed 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,39 @@ 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 persists immediately without a config patch`() { + val panel = requireUi() + assertTrue(KiloPluginSettings.getAutoEditorContext()) + + edt { + val editor = editorToggle(panel) + assertTrue(editor.isEnabled) + editor.doClick() + } + + assertFalse(KiloPluginSettings.getAutoEditorContext()) + edt { UIUtil.dispatchAllInvocationEvents() } + assertTrue(rpc.configPatches.isEmpty()) + } + private fun requireUi(): ContextSettingsUi = requireNotNull(ui) private fun threshold(panel: ContextSettingsUi): JBTextField = components(panel) @@ -284,6 +305,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() } From 9ebe1f6bd4e0a8537e59e1b51641860d7cb21edb Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 09:21:40 -0400 Subject: [PATCH 04/10] fix(jetbrains): align prompt attachment chips Remove the rounded outline from compact prompt attachment chips and let the prompt attachment container own left, right, and bottom padding so attached selections align with the prompt text. --- .../session/ui/attachment/AttachmentCard.kt | 16 --------- .../client/session/ui/style/SessionUiStyle.kt | 1 - .../session/views/PromptAttachmentView.kt | 5 +-- .../session/views/PromptAttachmentViewTest.kt | 35 +++++++++++++++++++ 4 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt 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 f433862985a..a54e683355a 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 @@ -65,7 +65,6 @@ class AttachmentChip( init { isOpaque = false - border = JBUI.Borders.empty(0, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HORIZONTAL_PADDING)) cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) toolTipText = tip accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name) @@ -85,21 +84,6 @@ class AttachmentChip( override fun getMinimumSize(): Dimension = preferredSize - override fun paintComponent(g: Graphics) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC) - g2.color = SessionUiStyle.View.Surface.bgColor() - g2.fillRoundRect(0, 0, width, height, arc, arc) - g2.color = SessionUiStyle.View.Outline.color() - g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) - } finally { - g2.dispose() - } - super.paintComponent(g) - } - private fun label(): String { val start = startLine val end = endLine 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 aa8ce97a96f..f610298b9b3 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 @@ -122,7 +122,6 @@ object SessionUiStyle { const val CLOSE_SIZE = 18 const val CORNER_ARC = 8 const val CHIP_HEIGHT = 28 - const val CHIP_HORIZONTAL_PADDING = 8 const val CHIP_ICON_GAP = 6 } 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 fb15e0e1d22..b828c659dad 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 @@ -36,11 +36,12 @@ class PromptAttachmentView( init { isOpaque = false + // Align the attachment chips with the prompt text: same left/right/bottom padding as PromptView. border = JBUI.Borders.empty( 0, - 0, + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - 0, + JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ) add(scroll) } 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..9e07af5ddae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt @@ -0,0 +1,35 @@ +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 com.intellij.testFramework.fixtures.BasePlatformTestCase + +class PromptAttachmentViewTest : BasePlatformTestCase() { + // The attachment strip should line up with the prompt text: same left, right, and bottom + // padding as PromptView so the selection reference reads as part of the prompt. + fun `test attachment padding matches prompt text`() { + val prompt = PromptView(Text("p1")).insets + val attach = PromptAttachmentView("m1") {}.insets + + assertEquals(prompt.left, attach.left) + assertEquals(prompt.right, attach.right) + assertEquals(prompt.bottom, attach.bottom) + } + + // 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) + } +} From 029391950696373acba5dfdf0f7b58711e0bc218 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 09:36:07 -0400 Subject: [PATCH 05/10] fix(jetbrains): trim prompt attachment panel chrome Remove the remaining border line from the prompt attachment scroll pane and keep only a small standard bottom inset below attached selections. --- .../session/views/PromptAttachmentView.kt | 9 ++++----- .../client/session/ui/SessionUiUpdateTest.kt | 7 +++---- .../session/views/PromptAttachmentViewTest.kt | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 13 deletions(-) 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 b828c659dad..919b114fe5d 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 @@ -28,6 +28,7 @@ class PromptAttachmentView( private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) private val scroll = JBScrollPane(row).apply { border = null + viewportBorder = null isOpaque = false viewport.isOpaque = false horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED @@ -36,11 +37,11 @@ class PromptAttachmentView( init { isOpaque = false - // Align the attachment chips with the prompt text: same left/right/bottom padding as PromptView. + // 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) @@ -87,7 +88,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 @@ -133,8 +134,6 @@ class PromptAttachmentView( return uri.scheme == "file" } - private fun bar() = scroll.horizontalScrollBar.preferredSize.height - private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } ?: tail(item.url).takeIf { it.isNotBlank() } ?: "attachment" 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 188ef09d37f..49ff27f5838 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 @@ -5,6 +5,7 @@ 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 @@ -467,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, ) 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 index 9e07af5ddae..457a8cdfd8e 100644 --- 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 @@ -3,18 +3,27 @@ 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 class PromptAttachmentViewTest : BasePlatformTestCase() { - // The attachment strip should line up with the prompt text: same left, right, and bottom - // padding as PromptView so the selection reference reads as part of the prompt. - fun `test attachment padding matches prompt text`() { + // 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(prompt.bottom, attach.bottom) + assertEquals(0, attach.top) + assertEquals(UiStyle.Gap.sm(), attach.bottom) + } + + fun `test attachment scroll pane has no border line`() { + val scroll = PromptAttachmentView("m1") {}.scrollPane() + + assertNull(scroll.border) + assertNull(scroll.viewportBorder) } // With the outline removed, the chip owns no internal padding; alignment comes from the From aaf2820162363a14f19bd43c8e8b1606f6cafe83 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 09:48:35 -0400 Subject: [PATCH 06/10] fix(jetbrains): handle prompt bulk updates and context apply Skip prompt editor height and highlight recalculation while the editor document is in bulk update, then schedule one refresh after bulk mode exits so undo/redo cannot trigger UnexpectedBulkUpdateStateException. Also include the local Auto-Include Editor Context setting in the Context page draft state so toggling it marks the configurable modified and Apply persists it without sending a CLI config patch. --- .../client/session/ui/prompt/PromptPanel.kt | 29 +++++++++++++++++ .../settings/context/ContextSettingsState.kt | 6 ++++ .../settings/context/ContextSettingsUi.kt | 31 +++++++++++++------ .../client/session/ui/PromptPanelTest.kt | 17 ++++++++++ .../settings/context/ContextSettingsUiTest.kt | 6 +++- 5 files changed, 78 insertions(+), 11 deletions(-) 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..1b5b656b85c 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 @@ -256,6 +257,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 +272,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) @@ -937,6 +949,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 +984,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/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 d6e4a8a4333..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 @@ -63,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) @@ -136,11 +149,9 @@ internal class ContextSettingsContent( ) : BaseContentPanel() { private val auto = SettingsToggle { value -> update { copy(auto = value) } } // Editor-context auto-include is a local per-IDE preference in PropertiesComponent (like - // autoApprove), applied immediately on toggle rather than through the CLI-backed draft/apply/ - // reset flow used by the other rows. It stays interactive even when the backend isn't READY. - private val editor = SettingsToggle(KiloPluginSettings.getAutoEditorContext()) { value -> - KiloPluginSettings.setAutoEditorContext(value) - } + // 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"), @@ -186,9 +197,9 @@ internal class ContextSettingsContent( @RequiresEdt fun sync(draft: ContextDraft, enabled: Boolean) { auto.isSelected = draft.auto - // Local preference: reflects PropertiesComponent and stays enabled regardless of the - // CLI-backed [enabled] gating that applies to the draft-driven rows below. - editor.isSelected = KiloPluginSettings.getAutoEditorContext() + // 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) 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..63d41c27f21 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 @@ -501,6 +502,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/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt index ac29c470bed..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 @@ -274,7 +274,7 @@ class ContextSettingsUiTest : BasePlatformTestCase() { flushUntil { rpc.configPatches.isNotEmpty() } } - fun `test editor context toggle persists immediately without a config patch`() { + fun `test editor context toggle marks modified and applies without a config patch`() { val panel = requireUi() assertTrue(KiloPluginSettings.getAutoEditorContext()) @@ -282,10 +282,14 @@ class ContextSettingsUiTest : BasePlatformTestCase() { 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()) } From 5c08bda580eb34b2f7fdb4ba009a0d2e7bb79e74 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 12:07:16 -0400 Subject: [PATCH 07/10] fix(jetbrains): underline prompt attachment file links Remove the remaining prompt attachment strip border and reuse the shared underlined file-link styling for attachment chip labels so clickable files look consistent in the transcript. --- .../client/session/ui/FileLinkText.kt | 14 ++++++++ .../session/ui/attachment/AttachmentCard.kt | 3 +- .../session/views/PromptAttachmentView.kt | 5 +-- .../client/session/views/tool/ToolSupport.kt | 15 ++++----- .../client/session/ui/SessionUiUpdateTest.kt | 2 +- .../session/views/PromptAttachmentViewTest.kt | 32 +++++++++++++++++-- 6 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt 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 a54e683355a..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 @@ -68,7 +69,7 @@ class AttachmentChip( cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) toolTipText = tip accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name) - val label = JBLabel(label()).apply { + val label = JBLabel(fileLinkHtml(label())).apply { icon = attachmentIcon(item.mime, item.name) iconTextGap = JBUI.scale(SessionUiStyle.View.Attachment.CHIP_ICON_GAP) toolTipText = tip 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 919b114fe5d..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 @@ -27,8 +27,9 @@ class PromptAttachmentView( private val cards = LinkedHashMap() private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) private val scroll = JBScrollPane(row).apply { - border = null - viewportBorder = 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 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/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 49ff27f5838..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 @@ -383,7 +383,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val chip = find(view, AttachmentChip::class.java) assertNotNull(chip) - assertTrue(findAll(chip!!, JBLabel::class.java).any { it.text == "HvJwtFilter.java:12-40" }) + 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`() { 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 index 457a8cdfd8e..27ad1470af7 100644 --- 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 @@ -5,6 +5,8 @@ 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 @@ -22,8 +24,14 @@ class PromptAttachmentViewTest : BasePlatformTestCase() { fun `test attachment scroll pane has no border line`() { val scroll = PromptAttachmentView("m1") {}.scrollPane() - assertNull(scroll.border) - assertNull(scroll.viewportBorder) + 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 @@ -41,4 +49,24 @@ class PromptAttachmentViewTest : BasePlatformTestCase() { 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) + } } From ad933a31dfa9c71a714e3675ba513118accc900a Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 13:34:46 -0400 Subject: [PATCH 08/10] feat(jetbrains): align prompt background with code fragments Give the prompt input and the transcript user-prompt bubble a dedicated background that defaults to the editor's code-fragment (code block) color, via a new Kilo.Session.Prompt.Background theme key so it can be restyled independently. Attachment strip is transparent so the prompt surface shows through, and the prompt-bar mode/model/effort pickers now blend into that surface when idle while keeping the standard hover fill. The transcript bubble drops its outline unless the prompt shares the session background, in which case it keeps the original outline for contrast. --- .../ui/prompt/PromptAttachmentStrip.kt | 3 +++ .../client/session/ui/prompt/PromptPanel.kt | 16 ++++++----- .../session/ui/style/SessionEditorStyle.kt | 12 ++++----- .../client/session/ui/style/SessionUiStyle.kt | 10 +++++++ .../client/session/views/MessageView.kt | 23 +++++++++------- .../client/session/views/PromptView.kt | 2 +- .../ai/kilocode/client/ui/PickerButton.kt | 27 +++++++++++++------ .../kotlin/ai/kilocode/client/ui/UiStyle.kt | 10 +++++++ .../ai/kilocode/client/ui/md/MdCommon.kt | 2 +- .../client/session/ui/PromptPanelTest.kt | 19 ++++++++----- .../client/session/views/TextViewTest.kt | 4 +-- 11 files changed, 87 insertions(+), 41 deletions(-) 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 1b5b656b85c..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 @@ -127,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 = {} @@ -411,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 } @@ -497,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() 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 f610298b9b3..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 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 7a55bc0d08a..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) @@ -439,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() } @@ -481,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() } 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/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/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 63d41c27f21..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 @@ -138,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`() { @@ -249,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)!! @@ -259,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`() { 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()) } From 9f32194345f707f94ac17da953833604e1a8d16f Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 14:11:34 -0400 Subject: [PATCH 09/10] test(jetbrains): stabilize ContextSettingsStateTest against platform app ContextDraft's default editor value reads a PropertiesComponent app service, so constructing it requires an initialized IntelliJ Application. As a plain unit test this only passed when another BasePlatformTestCase initialized the app first in the same fork, making it order-dependent. Extend BasePlatformTestCase so the Application is always available. --- .../context/ContextSettingsStateTest.kt | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) 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"))) } From 025bbdfe93f2f146ce83c12db390ed05ca638179 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 10 Aug 2026 15:27:26 -0400 Subject: [PATCH 10/10] fix(jetbrains): preserve session scroll follow intent Use the sticky tail-follow intent instead of momentary viewport position when deciding whether batched session updates and newly sent prompts should continue auto-scrolling. This prevents turn-close layout changes, such as modified-files cards plus progress footer removal, from cancelling an in-flight follow-to-bottom pass. --- .../ai/kilocode/client/session/SessionUi.kt | 4 +- .../client/session/SessionScrollTest.kt | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) 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 5068db342e2..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 @@ -156,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, @@ -684,7 +684,7 @@ class SessionUi( private fun sendPrompt(text: String, files: List) { if (text.isBlank() && files.isEmpty()) return prompt.clear() - val follow = scroll.atBottom() + val follow = scroll.following() val action = completion.clientAction(text) if (action != null) { action.action() 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)