diff --git a/.changeset/jetbrains-auto-editor-context.md b/.changeset/jetbrains-auto-editor-context.md new file mode 100644 index 00000000000..3a6b91d758b --- /dev/null +++ b/.changeset/jetbrains-auto-editor-context.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Include the active editor file, open files, visible files, and selected text in JetBrains chat context by default, with a Context settings toggle to disable it. Files matched by `.kilocodeignore` (or `.gitignore` plus `.env` files) are excluded, and the default shell is reported to the agent. diff --git a/.changeset/jetbrains-prompt-attachments.md b/.changeset/jetbrains-prompt-attachments.md new file mode 100644 index 00000000000..ac1f1f117e2 --- /dev/null +++ b/.changeset/jetbrains-prompt-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render prompt attachments inside the sent message bubble with file chips, image previews, and selection-aware file opening. diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index af1ad2c5507..9e015ef7191 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -5,7 +5,7 @@ diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml index 8fd300cd6c4..fc04e21f7df 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -5,7 +5,7 @@ diff --git a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml index 5ab397fabf5..f709383cb2b 100644 --- a/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml +++ b/packages/kilo-jetbrains/.run/runIdeSplitMode.run.xml @@ -6,7 +6,7 @@ diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 09e112dca15..85c08422b65 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.CustomModelDto import ai.kilocode.rpc.dto.CustomProviderConfigDto import ai.kilocode.rpc.dto.CustomProviderSaveDto import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageSummaryDto @@ -822,10 +823,27 @@ object KiloCliDataParser { if (variant != null) { sb.append(""","variant":${escape(variant)}""") } + val editor = prompt.editorContext + if (editor != null) { + sb.append(""","editorContext":${editorContextJson(editor)}""") + } sb.append("}") return sb.toString() } + private fun editorContextJson(ctx: EditorContextDto): String { + val fields = mutableListOf() + ctx.directory?.let { fields += "\"directory\":${escape(it)}" } + ctx.worktree?.let { fields += "\"worktree\":${escape(it)}" } + ctx.visibleFiles?.takeIf { it.isNotEmpty() }?.let { fields += "\"visibleFiles\":${array(it)}" } + ctx.openTabs?.takeIf { it.isNotEmpty() }?.let { fields += "\"openTabs\":${array(it)}" } + ctx.activeFile?.let { fields += "\"activeFile\":${escape(it)}" } + ctx.shell?.let { fields += "\"shell\":${escape(it)}" } + return "{${fields.joinToString(",")}}" + } + + private fun array(values: List): String = values.joinToString(",", "[", "]") { escape(it) } + private fun buildPromptPartJson(part: PromptPartDto): String { val fields = mutableListOf("\"type\":${escape(part.type)}") if (part.type == "file") { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 8140b86bffd..14f9cbe12da 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -26,6 +26,8 @@ import com.intellij.execution.process.CapturingProcessHandler import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager @@ -288,7 +290,7 @@ class KiloWorkspaceRpcApiImpl internal constructor( }.ifBlank { null } } - override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { + override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean { val item = clean(path) ?: return false val target = file(item)?.takeIf { it.isAbsolute } ?: return false val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false @@ -296,7 +298,7 @@ class KiloWorkspaceRpcApiImpl internal constructor( LOG.warn("No project available to open file: $path") return false } - navigate(project, vf, line, column) + navigate(project, vf, line, column, endLine) return true } @@ -374,8 +376,26 @@ class KiloWorkspaceRpcApiImpl internal constructor( null } - private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null) = suspendCancellableCoroutine { cont -> + private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null, endLine: Int? = null) = suspendCancellableCoroutine { cont -> ApplicationManager.getApplication().invokeLater({ + if (line != null && endLine != null) { + val editor = FileEditorManager.getInstance(project).openTextEditor( + OpenFileDescriptor(project, file, (line - 1).coerceAtLeast(0), 0), + true, + ) + val doc = editor?.document + if (editor != null && doc != null && doc.lineCount > 0) { + val start = (line - 1).coerceIn(0, doc.lineCount - 1) + val end = (endLine - 1).coerceIn(start, doc.lineCount - 1) + val from = doc.getLineStartOffset(start) + val to = doc.getLineEndOffset(end) + editor.selectionModel.setSelection(from, to) + editor.caretModel.moveToOffset(from) + editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + } + if (cont.isActive) cont.resume(Unit) + return@invokeLater + } val descriptor = if (line == null) { OpenFileDescriptor(project, file) } else { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt index 23afe339091..06c0259d1a1 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.cli import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -156,6 +157,27 @@ class ChatLogSummaryTest { assertTrue(out.contains("variant=medium"), out) } + @Test + fun `prompt dto summary includes editor context`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + + val out = ChatLogSummary.prompt( + PromptDto( + parts = listOf(PromptPartDto(type = "text", text = "hello")), + editorContext = EditorContextDto( + activeFile = "settings.gradle", + openTabs = listOf("settings.gradle", "src/App.kt"), + visibleFiles = listOf("settings.gradle"), + ), + ) + ) + + assertTrue(out.contains("editorContext=true"), out) + assertTrue(out.contains("activeFile=\"settings.gradle\""), out) + assertTrue(out.contains("openTabs=2"), out) + assertTrue(out.contains("visibleFiles=1"), out) + } + @Test fun `prompt dto summary redacts file attachment urls`() { System.setProperty("kilo.dev.log.chat.content", "preview") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index dab1d5b7138..132149fbfd7 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.McpConfigDto import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto import ai.kilocode.rpc.dto.PermissionReplyDto @@ -1958,6 +1959,25 @@ class KiloCliDataParserTest { assertEquals("""{"parts":[{"type":"text","text":"Hi"}],"noReply":true}""", result) } + @Test + fun `buildPromptJson - with editor context`() { + val prompt = PromptDto( + parts = listOf(PromptPartDto("text", "Hi")), + editorContext = EditorContextDto( + activeFile = "src/App.kt", + visibleFiles = listOf("src/App.kt"), + openTabs = listOf("src/App.kt", "src/Other.kt"), + ), + ) + + val result = KiloCliDataParser.buildPromptJson(prompt) + + assertEquals( + """{"parts":[{"type":"text","text":"Hi"}],"editorContext":{"visibleFiles":["src/App.kt"],"openTabs":["src/App.kt","src/Other.kt"],"activeFile":"src/App.kt"}}""", + result, + ) + } + @Test fun `buildProviderOAuthJson - numeric method index`() { val result = KiloCliDataParser.buildProviderOAuthJson("0", mapOf("deploymentType" to "github.com")) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 66c3625bd6e..33d628d2a2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -179,19 +179,19 @@ class KiloWorkspaceService internal constructor( } } - suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean { + suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean { val match = files(directory, path).firstOrNull() ?: return false return try { - call { openFile(match.path, line, column) } + call { openFile(match.path, line, column, endLine) } } catch (e: Exception) { LOG.warn("workspace file open failed for path=${match.path}", e) false } } - suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean { + suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean { return try { - call { openFile(path, line, column) } + call { openFile(path, line, column, endLine) } } catch (e: Exception) { LOG.warn("workspace file open failed for path=$path", e) false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt index 16641aab323..1a234402861 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt @@ -4,6 +4,7 @@ import com.intellij.ide.util.PropertiesComponent object KiloPluginSettings { private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove" + private const val AUTO_EDITOR_CONTEXT_KEY = "kilo.session.autoEditorContext" private const val PERMISSION_RULES_EXPANDED_KEY = "kilo.session.permissionRulesExpanded" fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false) @@ -16,6 +17,16 @@ object KiloPluginSettings { PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY) } + fun getAutoEditorContext(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_EDITOR_CONTEXT_KEY, true) + + fun setAutoEditorContext(value: Boolean) { + PropertiesComponent.getInstance().setValue(AUTO_EDITOR_CONTEXT_KEY, value.toString()) + } + + internal fun unsetAutoEditorContext() { + PropertiesComponent.getInstance().unsetValue(AUTO_EDITOR_CONTEXT_KEY) + } + fun getPermissionRulesExpanded(): Boolean = PropertiesComponent.getInstance().getBoolean(PERMISSION_RULES_EXPANDED_KEY, false) fun setPermissionRulesExpanded(value: Boolean) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt index daee52fea8f..88f3804098c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt @@ -59,7 +59,7 @@ class SessionFileLinks( } val target = parse(href) scope.launch { - val ok = service.openPath(dir, target.path, target.line, target.column) + val ok = service.openPath(dir, target.path, target.line, target.column, target.endLine) if (ok) { track(target, "direct") return@launch @@ -72,7 +72,7 @@ class SessionFileLinks( when (val result = decide(false, found)) { Resolution.Opened -> Unit is Resolution.OpenDirect -> { - val opened = service.openPath(dir, result.file.path, target.line, target.column) + val opened = service.openPath(dir, result.file.path, target.line, target.column, target.endLine) track(target, if (opened) "search_direct" else "missing") } is Resolution.Choose -> { @@ -104,7 +104,7 @@ class SessionFileLinks( .createPopupChooserBuilder(files) .setRenderer(FileRenderer()) .setItemChosenCallback { file -> - scope.launch { service.openPath(dir, file.path, target.line, target.column) } + scope.launch { service.openPath(dir, file.path, target.line, target.column, target.endLine) } } .createPopup() popup.show(anchor ?: RelativePoint.getCenterOf(root)) @@ -143,11 +143,11 @@ class SessionFileLinks( data object Missing : Resolution } - data class Target(val path: String, val line: Int? = null, val column: Int? = null) + data class Target(val path: String, val line: Int? = null, val column: Int? = null, val endLine: Int? = null) companion object { private const val FILE_SEARCH_LIMIT = 50 - private val LINE = Regex(":(\\d+)(?:-\\d+)?(?::(\\d+))?$") + private val LINE = Regex(":(\\d+)(?:-(\\d+))?(?::(\\d+))?$") private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):") fun parse(href: String): Target { @@ -155,6 +155,7 @@ class SessionFileLinks( return Target( href.substring(0, match.range.first), match.groupValues[1].toIntOrNull(), + match.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }?.toIntOrNull(), match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull(), ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index a7b30a796c8..28c4cd1750d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -47,6 +47,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.controller.EVENT_FLUSH_MS import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.context.EditorContextGatherer import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.permission.PermissionView @@ -155,7 +156,7 @@ class SessionUi( condense = Registry.`is`("kilo.session.condense", true), displayMs = displayMs, open = { item -> manager?.openSession(item) }, - beforeUpdate = { if (opening) false else scroll.atBottom() }, + beforeUpdate = { if (opening) false else scroll.following() }, afterUpdate = { if (!opening) scroll.followBottom(it) }, loaded = ::onSessionLoaded, openProfileAction = ::openProfileSettings, @@ -682,17 +683,8 @@ class SessionUi( private fun sendPrompt(text: String, files: List) { if (text.isBlank() && files.isEmpty()) return - val parts = buildList { - text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } - addAll(files) - } - LOG.debug { - val agent = controller.model.agent ?: "none" - val model = controller.model.model ?: "none" - "${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}" - } prompt.clear() - val follow = scroll.atBottom() + val follow = scroll.following() val action = completion.clientAction(text) if (action != null) { action.action() @@ -705,7 +697,20 @@ class SessionUi( scroll.followBottom(follow) return } - controller.prompt(text, files) + // Only the prompt path uses editor context; gather after the command branches so slash + // commands and client actions don't pay the editor-context cost or hit its failure modes. + val editor = EditorContextGatherer.gather(project, workspace.directory) + val allFiles = files + listOfNotNull(editor.selection) + LOG.debug { + val parts = buildList { + text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) } + addAll(allFiles) + } + val agent = controller.model.agent ?: "none" + val model = controller.model.model ?: "none" + "${ChatLogSummary.prompt(PromptDto(parts = parts, editorContext = editor.context))} agent=$agent model=$model ready=${controller.ready}" + } + controller.prompt(text, allFiles, editor.context) scroll.followBottom(follow) } @@ -918,12 +923,13 @@ class SessionUi( return } if (uri.scheme == "file") { - val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run { + val path = runCatching { Path.of(cleanAttachmentUri(uri)).toString() }.getOrNull() ?: run { LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}") return } - LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path") - fileLinks.open(path, null) + val target = attachmentHref(path, item) + LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$target") + fileLinks.open(target, null) return } LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}") @@ -934,6 +940,14 @@ class SessionUi( ?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() } ?: "attachment" + private fun attachmentHref(path: String, item: FileAttachment): String { + val start = item.startLine ?: return path + val end = item.endLine ?: start + return "$path:$start-$end" + } + + private fun cleanAttachmentUri(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null) + private fun attachmentUrl(url: String): String { val scheme = url.substringBefore(':', missingDelimiterValue = "none") return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt new file mode 100644 index 00000000000..ecf163c35fa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/EditorContextGatherer.kt @@ -0,0 +1,139 @@ +package ai.kilocode.client.session.context + +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.vfs.KiloVirtualFileSystem +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.EditorContextDto +import ai.kilocode.rpc.dto.PromptPartDto +import com.intellij.codeWithMe.ClientId +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.impl.EditorHistoryManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.util.EnvironmentUtil +import java.nio.file.Path +import kotlin.io.path.name + +/** + * Reads the active/open editors and current selection at prompt-send time. + * + * Split mode caveat: the chat UI runs under a non-local [ClientId], so + * [FileEditorManager.getOpenFiles]/[FileEditorManager.getSelectedTextEditor] take + * the per-client branch and return nothing. The `*WithRemotes` variants read the + * local composites directly and are the ones that actually see the user's tabs. + * These APIs are `@ApiStatus.Experimental`. + */ +internal object EditorContextGatherer { + private val LOG = KiloLog.create(EditorContextGatherer::class.java) + + // Resolved once per process; the login shell does not change during a session. + private val shell: String? by lazy { + if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL") + } + + data class Result( + val context: EditorContextDto?, + val selection: PromptPartDto?, + ) + + fun gather(project: Project, root: String): Result { + if (!KiloPluginSettings.getAutoEditorContext()) { + LOG.debug { "kind=editor-context enabled=false" } + return Result(null, null) + } + val manager = FileEditorManager.getInstance(project) + val base = Path.of(root).toAbsolutePath().normalize() + val openFiles = manager.openFilesWithRemotes + val editor = manager.selectedTextEditorWithRemotes.firstOrNull() + val activeFile = editor?.let { file(it) } ?: lastOpen(project, openFiles) ?: openFiles.firstOrNull() + val ignore = project.service().matcher(rootDir(listOfNotNull(activeFile) + openFiles, base)) + + fun keep(file: VirtualFile?): String? = rel(file, base)?.takeUnless { ignore.ignored(it) } + + val active = keep(activeFile) + val openRel = openFiles.mapNotNull { rel(it, base) }.distinct() + val open = openRel.filterNot { ignore.ignored(it) }.take(20) + val visible = (listOfNotNull(activeFile) + manager.selectedTextEditorWithRemotes.mapNotNull { file(it) }) + .mapNotNull { keep(it) } + .distinct() + .take(200) + val ctx = EditorContextDto( + activeFile = active, + openTabs = open.takeIf { it.isNotEmpty() }, + visibleFiles = visible.takeIf { it.isNotEmpty() }, + shell = shell, + ).takeIf { active != null || open.isNotEmpty() || visible.isNotEmpty() || shell != null } + val part = editor?.let { selection(it, base, ignore) } + LOG.debug { + val first = openFiles.firstOrNull() + val filtered = openRel.count { ignore.ignored(it) } + "kind=editor-context enabled=true localId=${ClientId.isCurrentlyUnderLocalId}" + + " rawOpen=${openFiles.size} rawSel=${manager.selectedTextEditorWithRemotes.size}" + + " active=${active ?: "none"} open=${open.size} visible=${visible.size} selection=${part != null}" + + " ignored=$filtered shell=${shell ?: "none"}" + + " firstFs=${first?.fileSystem?.protocol ?: "none"} firstLocal=${first?.isInLocalFileSystem ?: false}" + + " firstPath=${first?.path ?: "none"}" + } + return Result(ctx, part) + } + + private fun lastOpen(project: Project, open: List): VirtualFile? { + val set = open.toHashSet() + return EditorHistoryManager.getInstance(project).fileList.lastOrNull { it in set } + } + + // Walks up from an open editor file to the workspace-root directory so ignore + // files can be read via the same (possibly remote) VFS as the editor files. + private fun rootDir(files: List, root: Path): VirtualFile? { + for (file in files) { + var cur: VirtualFile? = file + while (cur != null) { + if (runCatching { Path.of(cur.path).toAbsolutePath().normalize() }.getOrNull() == root) return cur + cur = cur.parent + } + } + return null + } + + private fun selection(editor: Editor, root: Path, ignore: KiloIgnore): PromptPartDto? { + val model = editor.selectionModel + if (!model.hasSelection()) return null + val file = file(editor) ?: return null + val path = local(file, root) ?: return null + if (ignore.ignored(root.relativize(path).toString())) return null + val start = model.selectionStart + val end = model.selectionEnd + if (start == end) return null + val doc = editor.document + val last = (end - 1).coerceAtLeast(start) + val first = doc.getLineNumber(start) + 1 + val line = doc.getLineNumber(last) + 1 + val url = "${path.toUri()}?start=$first&end=$line" + return PromptPartDto( + type = "file", + mime = "text/plain", + url = url, + filename = path.name, + ) + } + + private fun file(editor: Editor): VirtualFile? = FileDocumentManager.getInstance().getFile(editor.document) + + private fun rel(file: VirtualFile?, root: Path): String? { + val path = file?.let { local(it, root) } ?: return null + return root.relativize(path).toString() + } + + private fun local(file: VirtualFile, root: Path): Path? { + if (file.fileSystem.protocol == KiloVirtualFileSystem.PROTOCOL) return null + // A host filename that is invalid on the client OS (e.g. `?`/`*` from a Linux host on + // a Windows frontend) throws InvalidPathException; drop the file instead of failing the send. + val path = runCatching { Path.of(file.path).toAbsolutePath().normalize() }.getOrNull() ?: return null + if (!path.startsWith(root)) return null + return path + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt new file mode 100644 index 00000000000..dedc4666f5a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnore.kt @@ -0,0 +1,141 @@ +package ai.kilocode.client.session.context + +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile + +/** + * Minimal `.gitignore`-style matcher used to keep ignored or sensitive files out + * of the editor context sent to the model. + * + * Mirrors the VS Code `FileIgnoreController` precedence: + * - if `.kilocodeignore` exists and is non-empty, use only its patterns (plus the + * `.kilocodeignore` file itself); + * - otherwise fall back to `.gitignore` plus the sensitive `.env` / `.env.*` + * patterns. + * + * Paths are matched as workspace-relative POSIX paths. Only the subset of gitignore + * syntax relevant to path filtering is supported: comments (`#`), blank lines, + * negation (`!`), anchoring (leading or embedded `/`), directory-only (trailing + * `/`), and the `*`, `**`, `?`, and `[..]` globs. + */ +internal class KiloIgnore private constructor(private val rules: List) { + + /** True when [path] (workspace-relative) should be excluded from editor context. */ + fun ignored(path: String): Boolean { + val norm = path.replace('\\', '/').trim('/') + if (norm.isEmpty()) return false + var hit = false + for (rule in rules) { + if (rule.regex.matches(norm)) hit = !rule.negate + } + return hit + } + + private class Rule(val regex: Regex, val negate: Boolean) + + companion object { + val EMPTY = KiloIgnore(emptyList()) + + const val KILO = ".kilocodeignore" + const val GIT = ".gitignore" + private val SENSITIVE = listOf(".env", ".env.*") + + /** + * Builds the matcher from the ignore files under [root]. Reads through the VFS + * so it works in remote/split mode where the workspace lives on the host. + * Returns [EMPTY] (allow-all) when [root] is null or unreadable; the backend + * permission layer still guards file contents. + */ + fun load(root: VirtualFile?): KiloIgnore { + if (root == null) return EMPTY + val kilo = read(root, KILO) + if (!kilo.isNullOrBlank()) return KiloIgnore(compile(kilo) + compile(KILO)) + val rules = mutableListOf() + read(root, GIT)?.takeIf { it.isNotBlank() }?.let { rules += compile(it) } + rules += SENSITIVE.mapNotNull { rule(it) } + return KiloIgnore(rules) + } + + /** Test seam: build a matcher directly from ignore-file text. */ + fun of(text: String): KiloIgnore = KiloIgnore(compile(text)) + + private fun read(root: VirtualFile, name: String): String? { + val file = root.findChild(name) ?: return null + if (!file.isValid || file.isDirectory) return null + return runCatching { VfsUtilCore.loadText(file) }.getOrNull() + } + + private fun compile(text: String): List = text.lineSequence().mapNotNull { rule(it) }.toList() + + private fun rule(raw: String): Rule? { + var line = raw.trimEnd() + if (line.isEmpty() || line.startsWith("#")) return null + val negate = line.startsWith("!") + if (negate) line = line.substring(1) + val dirOnly = line.endsWith("/") + if (dirOnly) line = line.trimEnd('/') + val leading = line.startsWith("/") + if (leading) line = line.trimStart('/') + if (line.isEmpty()) return null + val anchored = leading || line.contains('/') + val prefix = if (anchored) "" else "(?:.*/)?" + val suffix = if (dirOnly) "/.*" else "(?:/.*)?" + // A malformed character class (e.g. `[]`, `[z-a]`) yields an invalid Java regex. + // Skip the bad rule instead of letting PatternSyntaxException break every prompt send. + val regex = runCatching { Regex("^$prefix${glob(line)}$suffix$") }.getOrNull() ?: return null + return Rule(regex, negate) + } + + private fun glob(glob: String): String { + val sb = StringBuilder() + var i = 0 + while (i < glob.length) { + val c = glob[i] + when (c) { + '\\' -> { + val next = glob.getOrNull(i + 1) + if (next == null) sb.append("\\\\") + else { + if (!next.isLetterOrDigit()) sb.append('\\') + sb.append(next) + i++ + } + } + + '*' -> { + if (glob.getOrNull(i + 1) == '*') { + i++ + if (glob.getOrNull(i + 1) == '/') { + sb.append("(?:.*/)?") + i++ + } else { + sb.append(".*") + } + } else { + sb.append("[^/]*") + } + } + + '?' -> sb.append("[^/]") + + '[' -> { + val end = glob.indexOf(']', i + 1) + if (end == -1) { + sb.append("\\[") + } else { + val body = glob.substring(i + 1, end) + sb.append('[').append(if (body.startsWith("!")) "^${body.substring(1)}" else body).append(']') + i = end + } + } + + '.', '(', ')', '+', '|', '^', '$', '{', '}', ']' -> sb.append('\\').append(c) + + else -> sb.append(c) + } + i++ + } + return sb.toString() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt new file mode 100644 index 00000000000..170b144cb81 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/context/KiloIgnoreCache.kt @@ -0,0 +1,46 @@ +package ai.kilocode.client.session.context + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Caches the compiled [KiloIgnore] per workspace-root directory so editor-context + * gathering does not re-read and re-compile the ignore files on every prompt. + * + * The compiled matcher is reused until a `.kilocodeignore` or `.gitignore` change + * invalidates it via a VFS listener. This keeps the blocking VFS read (a remote `cwm` + * round-trip in split mode) off the prompt-send path after the first prompt, instead of + * repeating it for every message the user sends. + */ +@Service(Service.Level.PROJECT) +internal class KiloIgnoreCache : Disposable { + private val cache = ConcurrentHashMap() + + init { + ApplicationManager.getApplication().messageBus.connect(this) + .subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener { + override fun after(events: List) { + if (events.any { relevant(it) }) cache.clear() + } + }) + } + + /** Returns the cached matcher for [root], compiling and caching it on first use. */ + fun matcher(root: VirtualFile?): KiloIgnore { + if (root == null) return KiloIgnore.EMPTY + return cache.getOrPut(root.url) { KiloIgnore.load(root) } + } + + override fun dispose() = cache.clear() + + private fun relevant(event: VFileEvent): Boolean { + val name = event.path.substringAfterLast('/') + return name == KiloIgnore.KILO || name == KiloIgnore.GIT + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index a0f0013b98a..0ae42b857bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -31,6 +31,7 @@ import ai.kilocode.client.util.UiTimers import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto +import ai.kilocode.rpc.dto.EditorContextDto import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto @@ -261,11 +262,11 @@ class SessionController( } } - fun prompt(text: String, files: List = emptyList()) { + fun prompt(text: String, files: List = emptyList(), editorContext: EditorContextDto? = null) { assertEdt() val start = sid ?: ref?.key ?: "pending" val exists = sid != null - val dto = promptDto(text, files) + val dto = promptDto(text, files, editorContext) val props = promptProps(files) LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" } dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id -> @@ -1820,7 +1821,11 @@ class SessionController( } } - private fun promptDto(text: String, files: List = emptyList()): PromptDto { + private fun promptDto( + text: String, + files: List = emptyList(), + editorContext: EditorContextDto? = null, + ): PromptDto { val full = model.model val sel = full?.let(::parseModel) val variant = model.variant?.takeIf { it in model.variants } @@ -1834,6 +1839,7 @@ class SessionController( modelID = sel?.second, agent = model.agent, variant = variant, + editorContext = editorContext, ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index beee188bd70..225b88c6bbe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -73,6 +73,8 @@ class FileAttachment(id: String) : Content(id) { var url: String = "" var filename: String? = null var source: PartSourceDto? = null + var startLine: Int? = null + var endLine: Int? = null } /** Tool invocation with lifecycle state. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 1d918c70bcd..53878cd75f1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -498,6 +498,9 @@ class SessionModel { existing.url = dto.url ?: "" existing.filename = dto.filename existing.source = dto.source + val range = range(existing.url) + existing.startLine = range?.first + existing.endLine = range?.last } is Tool -> { val old = existing.childSessionId @@ -552,6 +555,9 @@ class SessionModel { url = dto.url ?: "" filename = dto.filename source = dto.source + val range = range(url) + startLine = range?.first + endLine = range?.last } "tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply { messageID = dto.messageID @@ -581,6 +587,20 @@ class SessionModel { for (l in listeners) l.onEvent(event) } + private fun range(url: String): IntRange? { + val query = runCatching { java.net.URI.create(url).rawQuery }.getOrNull() ?: return null + val args = query.split('&') + .mapNotNull { + val index = it.indexOf('=') + if (index < 0) return@mapNotNull null + it.substring(0, index) to it.substring(index + 1) + } + .toMap() + val start = args["start"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null + val end = args["end"]?.toIntOrNull()?.takeIf { it >= start } ?: start + return start..end + } + private fun trackChild(messageId: String, content: Content) { val tool = content as? Tool ?: return val child = tool.childSessionId ?: return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt new file mode 100644 index 00000000000..bcc402ef85e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/FileLinkText.kt @@ -0,0 +1,14 @@ +package ai.kilocode.client.session.ui + +import com.intellij.xml.util.XmlStringUtil + +internal fun fileLinkText(value: String): String = value.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString(" ") + +internal fun fileLinkHtml(value: String): String { + val text = fileLinkText(value) + if (text.isBlank()) return "" + return XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt index 2ae5cdc7596..b5b3d3ebc3b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui.attachment import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.ui.fileLinkHtml import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton @@ -47,6 +48,62 @@ data class AttachmentCardItem( val path: Path? = null, ) +class AttachmentChip( + private val item: AttachmentCardItem, + private val file: Boolean, + private val startLine: Int? = null, + private val endLine: Int? = null, + open: (() -> Unit)? = null, +) : JPanel(BorderLayout()) { + private val tip = tooltip(item) + private val open = open?.let { callback -> + object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + callback() + } + } + } + + init { + isOpaque = false + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = tip + accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name) + val label = JBLabel(fileLinkHtml(label())).apply { + icon = attachmentIcon(item.mime, item.name) + iconTextGap = JBUI.scale(SessionUiStyle.View.Attachment.CHIP_ICON_GAP) + toolTipText = tip + } + add(label, BorderLayout.CENTER) + watch(this) + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(size.width, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HEIGHT)) + } + + override fun getMinimumSize(): Dimension = preferredSize + + private fun label(): String { + val start = startLine + val end = endLine + if (file && start != null && end != null) return KiloBundle.message("session.attachment.file.range", item.name, start, end) + if (file) return item.name + return KiloBundle.message("session.attachment.unknown", item.mime.ifBlank { "unknown" }) + } + + private fun watch(node: Component) { + if (node is JComponent) node.toolTipText = tip + open?.let { + node.removeMouseListener(it) + node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + node.addMouseListener(it) + } + if (node is Container) node.components.forEach(::watch) + } +} + open class AttachmentCard( private val item: AttachmentCardItem, remove: (() -> Unit)? = null, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt index 9efa44aabf1..f4d850bdc68 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt @@ -23,6 +23,9 @@ class PromptAttachmentStrip( private val chips = LinkedHashMap() init { + // Transparent so the prompt shell background shows through instead of the strip + // painting its own panel background above the input surface. + isOpaque = false border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm()) isVisible = false } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index dbea7374493..7a4fcd32974 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -42,6 +42,7 @@ import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.openapi.actionSystem.IdeActions import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.SpellCheckingEditorCustomizationProvider import com.intellij.openapi.editor.colors.CodeInsightColors import com.intellij.openapi.editor.colors.TextAttributesKey @@ -126,11 +127,14 @@ class PromptPanel( private val INVALID_KEY = CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES } - val mode = ModePicker() + // Prompt-bar pickers blend into the prompt background when idle and only show the standard + // hover fill on pointer-over (idleFill = null paints nothing behind the label). + val mode = ModePicker().apply { idleFill = null } val model = ModelPicker().apply { placement = ModelPicker.Placement.ABOVE + idleFill = null } - val reasoning = ReasoningPicker() + val reasoning = ReasoningPicker().apply { idleFill = null } var onReset: () -> Unit = {} var onChange: () -> Unit = {} var onAutoApproveToggle: (Boolean) -> Unit = {} @@ -256,6 +260,7 @@ class PromptPanel( private var ready = false private var enhancing = false private var request = 0L + private var deferred = false override val isSendEnabled: Boolean get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) @@ -270,12 +275,22 @@ class PromptPanel( editor.addDocumentListener(object : DocumentListener { override fun documentChanged(e: DocumentEvent) { invalidateEnhancement() + if (e.document.isInBulkUpdate) { + deferEditorSync() + syncButton() + onChange() + return + } syncEditorHeight() triggerCompletion(e) syncHighlights() syncButton() onChange() } + + override fun bulkUpdateFinished(document: Document) { + deferEditorSync() + } }) shell.add(strip, BorderLayout.NORTH) shell.add(editor, BorderLayout.CENTER) @@ -399,7 +414,7 @@ class PromptPanel( @RequiresEdt private fun chrome(ed: EditorEx) { if (ed.isDisposed) return - style.applyPromptToEditor(ed) + style.applyPromptToEditor(ed, SessionUiStyle.View.Prompt.bgColor(style)) if (ed.isDisposed) return } @@ -485,11 +500,12 @@ class PromptPanel( @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = style.editorScheme.defaultBackground - shell.background = style.editorScheme.defaultBackground + val bg = SessionUiStyle.View.Prompt.bgColor(style) + background = bg + shell.background = bg style.applyTranscriptToField(editor) editor.getEditor(false)?.let(::chrome) - editor.background = style.editorBackground + editor.background = bg syncEditorHeight() syncAutoApprove() syncHighlights() @@ -937,6 +953,10 @@ class PromptPanel( @RequiresEdt private fun syncEditorHeight() { + if (editor.document.isInBulkUpdate) { + deferEditorSync() + return + } val before = editor.preferredSize.height val lower = editor.minimumSize.height editor.setPreferredSize(null) @@ -968,6 +988,19 @@ class PromptPanel( repaint() } + @RequiresEdt + private fun deferEditorSync() { + if (deferred) return + deferred = true + ApplicationManager.getApplication().invokeLater { + deferred = false + if (project.isDisposed || editor.document.isInBulkUpdate) return@invokeLater + syncEditorHeight() + syncHighlights() + syncButton() + } + } + @RequiresEdt private fun syncEditorScroll(ed: EditorEx?, overflow: Boolean) { // AS_NEEDED keeps the standard auto-hiding editor scrollbar (appears on diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 1a71b24c65a..66d4c18fef0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -80,7 +80,7 @@ data class SessionEditorStyle( } /** Apply the visible prompt-input text styling to embedded session editor components. */ - fun applyPromptToEditor(editor: EditorEx) { + fun applyPromptToEditor(editor: EditorEx, background: Color = editorBackground) { if (editor.isDisposed) return applyTranscriptToEditor(editor) if (editor.isDisposed) return @@ -92,11 +92,11 @@ data class SessionEditorStyle( 0, JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_HORIZONTAL_INSET), ) - editor.backgroundColor = editorBackground - editor.component.background = editorBackground - editor.contentComponent.background = editorBackground - editor.scrollPane.background = editorBackground - editor.scrollPane.viewport.background = editorBackground + editor.backgroundColor = background + editor.component.background = background + editor.contentComponent.background = background + editor.scrollPane.background = background + editor.scrollPane.viewport.background = background editor.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER editor.scrollPane.revalidate() editor.scrollPane.repaint() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 82cdcd40321..9a3af597cf7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -96,6 +96,16 @@ object SessionUiStyle { /** Prompt input dimensions and chrome inside the session view. */ object Prompt { + /** + * Background of the prompt input and the transcript user-prompt bubble. Uses a dedicated + * theme key so the prompt surface can be restyled independently, defaulting to the + * code-fragment background so the prompt matches rendered code blocks. + */ + fun bgColor(style: SessionEditorStyle): Color = JBColor.namedColor( + "Kilo.Session.Prompt.Background", + UiStyle.Colors.codeBlockBackground(style.editorScheme), + ) + const val EDITOR_LINES = 1 const val EDITOR_CHROME = 16 const val SEND_BUTTON_SIZE = 24 @@ -121,6 +131,8 @@ object SessionUiStyle { const val CARD_HEIGHT = 59 const val CLOSE_SIZE = 18 const val CORNER_ARC = 8 + const val CHIP_HEIGHT = 28 + const val CHIP_ICON_GAP = 6 } /** Full-session file drop overlay geometry and colors. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt index 95de4237000..7f21a7acf25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt @@ -6,12 +6,14 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.ui.attachment.AttachmentCard import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.attachment.AttachmentChip import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.UiStyle import com.intellij.util.ui.JBUI import java.awt.FlowLayout import java.net.URI import java.nio.file.Path +import javax.swing.JComponent class AttachmentView( private var item: FileAttachment, @@ -48,20 +50,31 @@ class AttachmentView( override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}" - private fun chip(item: FileAttachment) = AttachmentCard( - AttachmentCardItem(name(item), item.mime, item.url), - open = { openAttachment(item) }, - ) + private fun chip(item: FileAttachment): JComponent { + val card = AttachmentCardItem(name(item), item.mime, item.url) + if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) }) + return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) }) + } - private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename + private fun same(next: FileAttachment) = item.mime == next.mime && + item.url == next.url && + item.filename == next.filename && + item.startLine == next.startLine && + item.endLine == next.endLine + + private fun file(item: FileAttachment): Boolean { + if (item.source?.path?.isNotBlank() == true) return true + val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false + return uri.scheme == "file" + } companion object { fun openDefault(item: FileAttachment, openFile: SessionFileOpener, openUrl: (String) -> Unit) { val url = item.url.takeIf { it.isNotBlank() } ?: return val uri = runCatching { URI.create(url) }.getOrNull() ?: return if (uri.scheme == "file") { - val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return - openFile(path, null) + val path = runCatching { Path.of(clean(uri)).toString() }.getOrNull() ?: return + openFile(href(path, item), null) return } if (SessionFileLinks.isFileHref(url)) { @@ -70,6 +83,14 @@ class AttachmentView( } openUrl(url) } + + private fun href(path: String, item: FileAttachment): String { + val start = item.startLine ?: return path + val end = item.endLine ?: start + return "$path:$start-$end" + } + + private fun clean(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null) } private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index c4ae7dc75b8..710c0f93351 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -99,7 +99,7 @@ class MessageView( init { isOpaque = false - if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = SessionUiStyle.View.Prompt.bgColor(style) border = assistantBorder() // Populate content that already exists (e.g. after loadHistory) @@ -212,12 +212,11 @@ class MessageView( } } val view = view(content) - val item = wrapPrompt(view) view.resize = resize view.hover = hover view.applyStyle(style) parts[content.id] = view - add(item) + wrapPrompt(view)?.let { add(it) } } @RequiresEdt @@ -227,7 +226,9 @@ class MessageView( it.hover = hover it.applyStyle(style) attachments = it - add(it) + val node = ensurePromptWrap() + promptBox?.add(it, BorderLayout.SOUTH) + if (node.parent == null) add(node) } view.upsert(content) parts[content.id] = view @@ -253,20 +254,23 @@ class MessageView( @RequiresEdt private fun replacePart(content: Content, existing: PartView) { - val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount + // A replaced tool view is a direct child, so re-insert at its own slot. Only fall back to + // the prompt wrap's index when the replaced view is nested inside it, otherwise the wrap's + // lower index would push the replacement above the prompt bubble on user messages. + val at = (if (existing.parent !== this) components.indexOf(wrap) else components.indexOfFirst { it === existing }) + .takeIf { it >= 0 } ?: componentCount parts.remove(content.id) aliases.values.removeAll { it == content.id } sources.keys.removeAll { it !in aliases } - detach(existing) - remove(existing) + removeView(existing) + if (existing === prompt) prompt = null Disposer.dispose(existing) val view = view(content) - val item = wrapPrompt(view) view.resize = resize view.hover = hover view.applyStyle(style) parts[content.id] = view - add(item, at) + wrapPrompt(view)?.let { add(it, at) } syncBorder() refresh() } @@ -294,10 +298,11 @@ class MessageView( } aliases.values.removeAll { it == contentId } sources.keys.removeAll { it !in aliases } - detach(view) - remove(view) + removeView(view) Disposer.dispose(view) + if (view === prompt) prompt = null syncBorder() + syncPromptWrap() refresh() return true } @@ -325,10 +330,10 @@ class MessageView( @RequiresEdt private fun rebuildParts() { parts.values.distinct().forEach { - detach(it) - remove(it) + removeView(it) Disposer.dispose(it) } + wrap?.let { remove(it) } parts.clear() aliases.clear() sources.clear() @@ -434,7 +439,7 @@ class MessageView( @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style - if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = SessionUiStyle.View.Prompt.bgColor(style) for (view in parts.values) view.applyStyle(style) refresh() } @@ -442,10 +447,10 @@ class MessageView( @RequiresEdt override fun dispose() { parts.values.forEach { - detach(it) - remove(it) + removeView(it) Disposer.dispose(it) } + wrap?.let { remove(it) } parts.clear() aliases.clear() sources.clear() @@ -476,14 +481,17 @@ class MessageView( g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) val pt = if (box === this) Point() else SwingUtilities.convertPoint(box, Point(), this) - val x = pt.x - val y = pt.y - val w = box.width - 1 - val h = box.height - 1 - g2.color = style.editorScheme.defaultBackground - g2.fillRoundRect(x, y, box.width, box.height, arc, arc) - g2.color = SessionUiStyle.View.Outline.color() - if (w > 0 && h > 0) g2.drawRoundRect(x, y, w, h, arc, arc) + val bg = SessionUiStyle.View.Prompt.bgColor(style) + g2.color = bg + g2.fillRoundRect(pt.x, pt.y, box.width, box.height, arc, arc) + // When the prompt shares the session background there is no fill contrast, so draw the + // outline to keep the bubble visible. + if (bg.rgb == style.editorBackground.rgb) { + val w = box.width - 1 + val h = box.height - 1 + g2.color = SessionUiStyle.View.Outline.color() + if (w > 0 && h > 0) g2.drawRoundRect(pt.x, pt.y, w, h, arc, arc) + } } finally { g2.dispose() } @@ -502,19 +510,42 @@ class MessageView( } @RequiresEdt - private fun wrapPrompt(view: PartView): JComponent { + private fun removeView(view: PartView) { + detach(view) + view.parent?.remove(view) + } + + @RequiresEdt + private fun wrapPrompt(view: PartView): JComponent? { if (role != SessionUiStyle.View.Message.USER_ROLE) return view if (view !is PromptView) return view prompt = view + val node = ensurePromptWrap() + val box = promptBox ?: return node + if (view.parent !== box) box.add(view, BorderLayout.CENTER) + node.bar.setActive(true) + return node.takeIf { it.parent == null } + } + + @RequiresEdt + private fun ensurePromptWrap(): PromptWrap { + val existing = wrap + if (existing != null) return existing val box = JPanel(BorderLayout()).also { it.isOpaque = false - it.add(view, BorderLayout.CENTER) promptBox = it } - val node = PromptWrap(box) - wrap = node - node.bar.setActive(true) - return node + return PromptWrap(box).also { wrap = it } + } + + @RequiresEdt + private fun syncPromptWrap() { + val node = wrap ?: return + val box = promptBox ?: return + if (box.componentCount > 0) return + node.parent?.remove(node) + wrap = null + promptBox = null } private inner class PromptWrap( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt index a01148a25fc..331499655e3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.ui.attachment.AttachmentCard import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.attachment.AttachmentChip import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.UiStyle @@ -12,6 +13,8 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Dimension +import java.net.URI +import javax.swing.JComponent import javax.swing.ScrollPaneConstants class PromptAttachmentView( @@ -21,10 +24,12 @@ class PromptAttachmentView( override val contentId: String = "attachments:$messageId" private val items = LinkedHashMap() - private val cards = LinkedHashMap() + private val cards = LinkedHashMap() private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) private val scroll = JBScrollPane(row).apply { - border = null + // Empty borders remove the visible scroll pane frame; null can be replaced by the current UI. + border = JBUI.Borders.empty() + viewportBorder = JBUI.Borders.empty() isOpaque = false viewport.isOpaque = false horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED @@ -33,10 +38,11 @@ class PromptAttachmentView( init { isOpaque = false + // Align the attachment chips with the prompt text horizontally, with only a small bottom inset. border = JBUI.Borders.empty( 0, JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + UiStyle.Gap.sm(), JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), ) add(scroll) @@ -83,7 +89,7 @@ class PromptAttachmentView( override fun getPreferredSize(): Dimension { val ins = insets val pref = scroll.preferredSize - return Dimension(0, pref.height + bar() + ins.top + ins.bottom) + return Dimension(0, pref.height + ins.top + ins.bottom) } override fun getMinimumSize() = preferredSize @@ -111,14 +117,23 @@ class PromptAttachmentView( repaint() } - private fun card(item: FileAttachment) = AttachmentCard( - AttachmentCardItem(name(item), item.mime, item.url), - open = { openAttachment(item) }, - ) + private fun card(item: FileAttachment): JComponent { + val card = AttachmentCardItem(name(item), item.mime, item.url) + if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) }) + return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) }) + } - private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename + private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && + a.url == b.url && + a.filename == b.filename && + a.startLine == b.startLine && + a.endLine == b.endLine - private fun bar() = scroll.horizontalScrollBar.preferredSize.height + private fun file(item: FileAttachment): Boolean { + if (item.source?.path?.isNotBlank() == true) return true + val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false + return uri.scheme == "file" + } private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } ?: tail(item.url).takeIf { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt index 7d3f7e267e9..32ddf7c21ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt @@ -74,7 +74,7 @@ class PromptView( override fun styleFont(style: SessionEditorStyle) = style.transcriptFont - override fun styleBackground(style: SessionEditorStyle) = style.editorBackground + override fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.View.Prompt.bgColor(style) private fun sync() { md.set(linkifyMentions(buffer.toString(), mentions)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index a17b164e31d..3eabce5da94 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -7,6 +7,8 @@ import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.session.ui.fileLinkHtml +import ai.kilocode.client.session.ui.fileLinkText import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -135,8 +137,8 @@ class FileLinkLabel( @RequiresEdt fun setTarget(path: String?, text: String): Boolean { - val next = single(text.ifBlank { path.orEmpty() }) - val value = if (next.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(next)}") + val next = fileLinkText(text.ifBlank { path.orEmpty() }) + val value = fileLinkHtml(next) var changed = false if (href != path) { href = path @@ -489,7 +491,7 @@ internal fun setText(label: JBLabel, text: String): Boolean { @RequiresEdt internal fun setTargetText(label: JBLabel, text: String): Boolean { - val value = single(text) + val value = fileLinkText(text) if (label.text == value) return false label.text = value return true @@ -511,16 +513,11 @@ private fun clip(label: T): T = label.apply { } private fun html(text: String): String { - val value = single(text) + val value = fileLinkText(text) if (value.isBlank()) return "" return XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(value)}") } -private fun single(text: String): String = text.lineSequence() - .map { it.trim() } - .filter { it.isNotEmpty() } - .joinToString(" ") - @RequiresEdt internal fun show(parts: ToolParts, link: Boolean): Boolean { var changed = false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt index 3a5989a3325..6de6c44133d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsState.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.settings.context +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.rpc.dto.CompactionPatchDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.ConfigPatchDto @@ -9,6 +10,7 @@ internal data class ContextDraft( val auto: Boolean = false, val threshold: String = "", val prune: Boolean = false, + val editor: Boolean = KiloPluginSettings.getAutoEditorContext(), val ignore: List = emptyList(), ) @@ -21,6 +23,7 @@ internal fun contextDraft(config: ConfigDto?): ContextDraft = ContextDraft( auto = config?.compaction?.auto ?: false, threshold = config?.compaction?.threshold_percent?.let(::formatThreshold).orEmpty(), prune = config?.compaction?.prune ?: false, + editor = KiloPluginSettings.getAutoEditorContext(), ignore = config?.watcher?.ignore ?: emptyList(), ) @@ -38,8 +41,11 @@ internal fun savedMatches(base: ContextDraft, draft: ContextDraft): Boolean = base.auto == draft.auto && normalizeThreshold(base.threshold) == normalizeThreshold(draft.threshold) && base.prune == draft.prune && + base.editor == draft.editor && base.ignore == draft.ignore +internal fun localChanged(base: ContextDraft, draft: ContextDraft): Boolean = base.editor != draft.editor + internal fun thresholdStatus(value: String): ThresholdStatus { val text = value.trim() if (text.isBlank()) return ThresholdStatus.VALID diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt index 7a66f2ac59a..8b156065b82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/context/ContextSettingsUi.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.settings.context import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.settings.base.BaseContentPanel import ai.kilocode.client.settings.base.BaseSettingsUi import ai.kilocode.client.settings.base.SettingsBannerKind @@ -62,10 +63,23 @@ internal class ContextSettingsUi( startSettings(ContextSettingsContent { updateDraft(it) }) } - override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? = patch(from, to)?.takeIf(::changed) + override fun change(from: ContextDraft, to: ContextDraft): ConfigPatchDto? { + val patch = patch(from, to) ?: return null + if (changed(patch)) return patch + return ConfigPatchDto().takeIf { localChanged(from, to) } + } override fun save(change: ConfigPatchDto, done: (KiloAppStateDto?) -> Unit) { - app.updateConfigAsync(change, done) + val value = draft.editor + if (!changed(change)) { + KiloPluginSettings.setAutoEditorContext(value) + done(appState) + return + } + app.updateConfigAsync(change) { result -> + if (result != null) KiloPluginSettings.setAutoEditorContext(value) + done(result) + } } override fun base(result: KiloAppStateDto): ContextDraft = contextDraft(result.config) @@ -134,6 +148,10 @@ internal class ContextSettingsContent( private val update: (ContextDraft.() -> ContextDraft) -> Unit, ) : BaseContentPanel() { private val auto = SettingsToggle { value -> update { copy(auto = value) } } + // Editor-context auto-include is a local per-IDE preference in PropertiesComponent (like + // autoApprove). It participates in this page's draft/apply/reset state so the Configurable + // Apply button reflects unsaved local changes, but it is never sent as CLI config. + private val editor = SettingsToggle { value -> update { copy(editor = value) } } private val prune = SettingsToggle { value -> update { copy(prune = value) } } private val threshold = ThresholdField( KiloBundle.message("settings.context.compaction.threshold.placeholder"), @@ -163,6 +181,13 @@ internal class ContextSettingsContent( prune, )) } + section( + KiloBundle.message("settings.context.editor.title"), + ).row(SettingsRow( + KiloBundle.message("settings.context.editor.auto.title"), + KiloBundle.message("settings.context.editor.auto.description"), + editor, + )) section( KiloBundle.message("settings.context.watcher.title"), KiloBundle.message("settings.context.watcher.description"), @@ -172,6 +197,10 @@ internal class ContextSettingsContent( @RequiresEdt fun sync(draft: ContextDraft, enabled: Boolean) { auto.isSelected = draft.auto + // Local preference: draft-driven, but still enabled regardless of the CLI-backed [enabled] + // gating that applies to the remote config rows below. + editor.isSelected = draft.editor + editor.isEnabled = true prune.isSelected = draft.prune threshold.sync(draft.threshold) patterns.sync(draft.ignore) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt index 86b1b6763ef..f2ccbdd8564 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.ui import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI +import java.awt.Color import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints @@ -11,6 +12,13 @@ import java.awt.event.MouseEvent open class PickerButton : JBLabel() { private var over = false + /** + * Idle (unhovered) fill. Defaults to the standard picker surface; set to `null` to paint + * nothing so the picker blends into its container (e.g. the prompt background). The hover + * fill is unaffected. + */ + var idleFill: Color? = UiStyle.Colors.picker() + init { border = pickerBorder() background = UiStyle.Colors.picker() @@ -34,14 +42,17 @@ open class PickerButton : JBLabel() { } override fun paintComponent(g: Graphics) { - val g2 = g.create() as Graphics2D - try { - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else UiStyle.Colors.picker() - val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) - g2.fillRoundRect(0, 0, width, height, arc, arc) - } finally { - g2.dispose() + val fill = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else idleFill + if (fill != null) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2.color = fill + val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) + g2.fillRoundRect(0, 0, width, height, arc, arc) + } finally { + g2.dispose() + } } super.paintComponent(g) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 7e711204661..b874ed74910 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.ui +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.colors.EditorColorsManager +import com.intellij.openapi.editor.colors.EditorColorsScheme import com.intellij.ui.JBColor import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI @@ -118,6 +120,14 @@ object UiStyle { /** Uses the editor background so chat cards feel native beside editor content. */ fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground } + /** + * Background for rendered code fragments (markdown code blocks). Uses the editor's doc + * code-block attribute background and falls back to the editor background when the theme + * leaves it unset. + */ + fun codeBlockBackground(scheme: EditorColorsScheme): Color = + scheme.getAttributes(DefaultLanguageHighlighterColors.DOC_CODE_BLOCK)?.backgroundColor ?: scheme.defaultBackground + /** * Contained panel background: follows the active theme's text-field/input surface. * Falls back to the panel background when unavailable. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt index ac73c1400a5..d95db32ee09 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdCommon.kt @@ -94,7 +94,7 @@ internal object MdCommon { ?: fg(style, DefaultLanguageHighlighterColors.DOC_COMMENT) ?: UIUtil.getContextHelpForeground() val border = color(style, EditorColors.PREVIEW_BORDER_COLOR) ?: UiStyle.Colors.contentBorder() - val blockBg = bg(style, DefaultLanguageHighlighterColors.DOC_CODE_BLOCK) ?: style.editorBackground + val blockBg = UiStyle.Colors.codeBlockBackground(style.editorScheme) return MdStyle( font = style.transcriptFont, foreground = style.editorForeground, diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 074f5942042..2c1c665a0ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -227,6 +227,8 @@ session.attachment.unsupported=Cannot preview {0} session.attachment.mime=Type: {0} session.attachment.size=Size: {0} bytes session.attachment.error=Failed to load attachment: {0} +session.attachment.file.range={0}:{1}-{2} +session.attachment.unknown=Attached content ({0}) prompt.action.enhance=Enhance prompt prompt.action.enhance.loading=Enhancing prompt... prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works. @@ -363,6 +365,9 @@ settings.context.compaction.threshold.suffix=% settings.context.compaction.threshold.invalid=Enter a number from 0 to 100, or leave the field blank. settings.context.compaction.prune.title=Prune Old Outputs settings.context.compaction.prune.description=Remove old tool outputs during compaction +settings.context.editor.title=Editor Context +settings.context.editor.auto.title=Auto-Include Editor Context +settings.context.editor.auto.description=Include the active file, open files, visible files, and selected text when sending chat messages. settings.context.watcher.title=File Watcher Ignore Patterns settings.context.watcher.description=Glob patterns for files the watcher should ignore settings.context.watcher.add=Add pattern diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt index 13c56f27f06..1294933f4a8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt @@ -41,7 +41,7 @@ class SessionFileLinksTest : BasePlatformTestCase() { fun `test parse strips line range and column suffixes`() { assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12")) - assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12-20")) + assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, endLine = 20), SessionFileLinks.parse("src/Foo.kt:12-20")) assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, column = 3), SessionFileLinks.parse("src/Foo.kt:12:3")) } @@ -96,6 +96,18 @@ class SessionFileLinksTest : BasePlatformTestCase() { assertEquals("true", events.single().second["hasLine"]) } + fun `test open forwards line range to workspace service`() = runBlocking { + val file = WorkspaceFileDto("/test/src/Foo.kt", "Foo.kt") + val done = CompletableDeferred() + rpc.fileResolver = { path -> if (path == "src/Foo.kt") listOf(file) else emptyList() } + val links = SessionFileLinks("/test", service, scope, JPanel(), openUrl = {}) { _, _ -> done.complete(Unit) } + + links.open("src/Foo.kt:12-20", null) + withTimeout(OPEN_TIMEOUT_MS) { done.await() } + + assertEquals(listOf(FakeWorkspaceRpcApi.Opened("/test/src/Foo.kt", 12, null, 20)), rpc.openedFiles) + } + private companion object { const val OPEN_TIMEOUT_MS = 5_000L } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index 70d9ffeec8f..f9685fd2c53 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -498,6 +498,61 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test turn close after modified files keeps pending tail follow`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setBottom(bar) + + emit(ChatEventDto.TurnOpen("ses_test")) + drainScroll() + assertBottom(bar) + assertTrue(ui.scroll.following()) + val id = "modified_close_tail" + val pid = "modified_close_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id).copy(summary = MessageSummaryDto(listOf(modifiedFile())))), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "tail line\n".repeat(160))), flush = false) + forceFlushWithoutDispatch() + + emit(ChatEventDto.TurnClose("ses_test", "completed")) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + + findAll(ui).first().text = "next prompt" + find(ui).send() + settleShort(100) + val text = rpc.prompts.last().third.parts.single().text + val next = "modified_close_next" + emit(ChatEventDto.MessageUpdated("ses_test", message(next)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("modified_close_next_part", next, "text", text)), flush = false) + forceFlush() + drainScroll() + + assertBottom(bar) + assertFalse(jumpButton().isVisible) + } + + fun `test turn close after modified files preserves user scroll position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", message("modified_close_middle").copy(summary = MessageSummaryDto(listOf(modifiedFile())))), flush = false) + emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = false) + forceFlush() + drainScroll() + + assertEquals(value, bar.value) + assertFalse(ui.scroll.following()) + assertTrue(jumpButton().isVisible) + } + fun `test prompt editor growth preserves middle scroll position`() { showMessages() fillTranscript(24) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt new file mode 100644 index 00000000000..2e7fd549490 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/EditorContextGathererTest.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.context + +import ai.kilocode.client.plugin.KiloPluginSettings +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.util.SystemInfo +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.EnvironmentUtil +import com.intellij.util.ui.UIUtil + +class EditorContextGathererTest : BasePlatformTestCase() { + override fun tearDown() { + try { + KiloPluginSettings.unsetAutoEditorContext() + } finally { + super.tearDown() + } + } + + fun `test gather includes active open visible files and selected range`() { + val psi = myFixture.addFileToProject( + "src/App.kt", + "fun main() {\n println(\"hi\")\n println(\"bye\")\n}\n", + ) + val manager = FileEditorManager.getInstance(project) + manager.openFile(psi.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val editor = manager.selectedTextEditor!! + val doc = editor.document + editor.selectionModel.setSelection(doc.getLineStartOffset(1), doc.getLineEndOffset(2)) + val root = psi.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertEquals("src/App.kt", result.context?.activeFile) + assertEquals(listOf("src/App.kt"), result.context?.openTabs) + assertEquals(listOf("src/App.kt"), result.context?.visibleFiles) + assertEquals("text/plain", result.selection?.mime) + assertEquals("App.kt", result.selection?.filename) + assertTrue(result.selection?.url, result.selection?.url.orEmpty().contains("/src/App.kt?start=2&end=3")) + val expectedShell = if (SystemInfo.isWindows) EnvironmentUtil.getValue("COMSPEC") else EnvironmentUtil.getValue("SHELL") + assertEquals(expectedShell, result.context?.shell) + } + + fun `test gather filters kilocodeignore files from open tabs`() { + val app = myFixture.addFileToProject("src/App.kt", "fun main() {}") + val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1") + myFixture.addFileToProject(".kilocodeignore", "ignored/\n") + val manager = FileEditorManager.getInstance(project) + manager.openFile(secret.virtualFile, true) + manager.openFile(app.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val root = app.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertEquals("src/App.kt", result.context?.activeFile) + assertEquals(listOf("src/App.kt"), result.context?.openTabs) + assertEquals(listOf("src/App.kt"), result.context?.visibleFiles) + } + + fun `test gather drops selection when active file is ignored`() { + val secret = myFixture.addFileToProject("ignored/Secret.kt", "val token = 1\nval other = 2\n") + myFixture.addFileToProject(".kilocodeignore", "ignored/\n") + val manager = FileEditorManager.getInstance(project) + manager.openFile(secret.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + val editor = manager.selectedTextEditor!! + val doc = editor.document + editor.selectionModel.setSelection(doc.getLineStartOffset(0), doc.getLineEndOffset(0)) + val root = secret.virtualFile.parent.parent.path + + val result = EditorContextGatherer.gather(project, root) + + assertNull(result.context?.activeFile) + assertNull(result.context?.openTabs) + assertNull(result.context?.visibleFiles) + assertNull(result.selection) + } + + fun `test gather returns empty when setting is off`() { + KiloPluginSettings.setAutoEditorContext(false) + val psi = myFixture.addFileToProject("src/App.kt", "fun main() {}") + FileEditorManager.getInstance(project).openFile(psi.virtualFile, true) + UIUtil.dispatchAllInvocationEvents() + + val result = EditorContextGatherer.gather(project, psi.virtualFile.parent.parent.path) + + assertNull(result.context) + assertNull(result.selection) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt new file mode 100644 index 00000000000..aca6828d293 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreCacheTest.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.session.context + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil + +class KiloIgnoreCacheTest : BasePlatformTestCase() { + fun `test matcher caches until ignore file changes`() { + val file = myFixture.addFileToProject(".kilocodeignore", "ignored/\n").virtualFile + val root = file.parent + val cache = project.service() + + val first = cache.matcher(root) + assertTrue(first.ignored("ignored/Secret.kt")) + assertFalse(first.ignored("src/App.kt")) + assertSame(first, cache.matcher(root)) + + ApplicationManager.getApplication().runWriteAction { + VfsUtil.saveText(file, "src/\n") + } + UIUtil.dispatchAllInvocationEvents() + + val second = cache.matcher(root) + assertNotSame(first, second) + assertFalse(second.ignored("ignored/Secret.kt")) + assertTrue(second.ignored("src/App.kt")) + } + + fun `test null root allows everything`() { + assertSame(KiloIgnore.EMPTY, project.service().matcher(null)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt new file mode 100644 index 00000000000..1dedb3b4aec --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/context/KiloIgnoreTest.kt @@ -0,0 +1,95 @@ +package ai.kilocode.client.session.context + +import junit.framework.TestCase + +class KiloIgnoreTest : TestCase() { + fun `test empty allows everything`() { + val ignore = KiloIgnore.of("") + assertFalse(ignore.ignored("src/App.kt")) + assertFalse(ignore.ignored(".env")) + } + + fun `test basename matches at any depth`() { + val ignore = KiloIgnore.of("foo") + assertTrue(ignore.ignored("foo")) + assertTrue(ignore.ignored("a/b/foo")) + assertTrue(ignore.ignored("foo/child.txt")) + assertFalse(ignore.ignored("a/foobar")) + } + + fun `test extension glob`() { + val ignore = KiloIgnore.of("*.log") + assertTrue(ignore.ignored("a.log")) + assertTrue(ignore.ignored("nested/dir/a.log")) + assertFalse(ignore.ignored("a.log.kt")) + } + + fun `test directory only pattern matches contents`() { + val ignore = KiloIgnore.of("node_modules/") + assertTrue(ignore.ignored("node_modules/pkg/index.js")) + assertTrue(ignore.ignored("a/node_modules/pkg.js")) + assertFalse(ignore.ignored("node_modules")) + } + + fun `test leading slash anchors to root`() { + val ignore = KiloIgnore.of("/build") + assertTrue(ignore.ignored("build/out.js")) + assertFalse(ignore.ignored("src/build/out.js")) + } + + fun `test middle slash anchors to root`() { + val ignore = KiloIgnore.of("src/generated") + assertTrue(ignore.ignored("src/generated/A.kt")) + assertFalse(ignore.ignored("app/src/generated/A.kt")) + } + + fun `test double star matches across directories`() { + val ignore = KiloIgnore.of("**/dist") + assertTrue(ignore.ignored("dist/a.js")) + assertTrue(ignore.ignored("a/b/dist/a.js")) + + val nested = KiloIgnore.of("src/**/*.tmp") + assertTrue(nested.ignored("src/a/b/c.tmp")) + assertTrue(nested.ignored("src/x.tmp")) + assertFalse(nested.ignored("lib/a.tmp")) + } + + fun `test negation re-includes`() { + val ignore = KiloIgnore.of("*.log\n!keep.log") + assertTrue(ignore.ignored("debug.log")) + assertFalse(ignore.ignored("keep.log")) + } + + fun `test comments and blank lines ignored`() { + val ignore = KiloIgnore.of("# a comment\n\n*.secret\n") + assertTrue(ignore.ignored("api.secret")) + assertFalse(ignore.ignored("# a comment")) + } + + fun `test sensitive env patterns`() { + val ignore = KiloIgnore.of(".env\n.env.*") + assertTrue(ignore.ignored(".env")) + assertTrue(ignore.ignored(".env.local")) + assertTrue(ignore.ignored("cfg/.env.production")) + assertFalse(ignore.ignored("env")) + assertFalse(ignore.ignored("environment.ts")) + } + + fun `test char class`() { + val ignore = KiloIgnore.of("*.[oa]") + assertTrue(ignore.ignored("main.o")) + assertTrue(ignore.ignored("lib.a")) + assertFalse(ignore.ignored("main.c")) + } + + fun `test backslash separators normalized`() { + val ignore = KiloIgnore.of("node_modules/") + assertTrue(ignore.ignored("a\\node_modules\\pkg.js")) + } + + fun `test malformed char class is skipped without throwing`() { + val ignore = KiloIgnore.of("[z-a]\n[]\n[!]\n*.log") + assertTrue(ignore.ignored("debug.log")) + assertFalse(ignore.ignored("src/App.kt")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt new file mode 100644 index 00000000000..53cd3e1b05f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/EditorContextPromptTest.kt @@ -0,0 +1,30 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.rpc.dto.EditorContextDto +import ai.kilocode.rpc.dto.PromptPartDto +import kotlin.test.assertEquals + +class EditorContextPromptTest : SessionControllerTestBase() { + fun `test prompt forwards editor context`() { + val (c, _, _) = prompted() + rpc.prompts.clear() + val ctx = EditorContextDto( + activeFile = "src/App.kt", + openTabs = listOf("src/App.kt"), + visibleFiles = listOf("src/App.kt"), + ) + val file = PromptPartDto( + type = "file", + mime = "text/plain", + url = "file:///test/src/App.kt?start=2&end=3", + filename = "App.kt", + ) + + edt { c.prompt("explain", listOf(file), ctx) } + flush() + + val prompt = rpc.prompts.single().third + assertEquals(ctx, prompt.editorContext) + assertEquals(file, prompt.parts.first { it.type == "file" }) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index cb787b2b77d..3f9c0e22fa5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -70,6 +70,7 @@ import com.intellij.ui.components.JBLabel import com.intellij.util.Producer import com.intellij.util.ui.EmptyIcon import com.intellij.ui.scale.JBUIScale +import com.intellij.util.DocumentUtil import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CompletableDeferred @@ -137,11 +138,11 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(style.transcriptFont.size, font.size) } - fun `test prompt input uses editor background`() { + fun `test prompt input uses prompt background`() { val style = SessionEditorStyle.current() val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) - assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background) + assertEquals(SessionUiStyle.View.Prompt.bgColor(style), panel.defaultFocusedComponent.background) } fun `test prompt editor hides floating toolbar`() { @@ -248,7 +249,12 @@ class PromptPanelTest : BasePlatformTestCase() { HighlighterColors.TEXT, TextAttributes(Color(0xEA, 0xEA, 0xEA), bg, null, null, Font.PLAIN), ) + scheme.setAttributes( + DefaultLanguageHighlighterColors.DOC_CODE_BLOCK, + TextAttributes(null, bg, null, null, Font.PLAIN), + ) val style = SessionEditorStyle.create(scheme = scheme) + val promptBg = SessionUiStyle.View.Prompt.bgColor(style) realize(panel, 260, 400) val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!! @@ -258,11 +264,11 @@ class PromptPanelTest : BasePlatformTestCase() { panel.applyStyle(style) - assertEquals(bg, panel.defaultFocusedComponent.background) - assertEquals(bg, editor.backgroundColor) - assertEquals(bg, editor.scrollPane.background) - assertEquals(bg, editor.scrollPane.viewport.background) - assertEquals(bg, editor.contentComponent.background) + assertEquals(promptBg, panel.defaultFocusedComponent.background) + assertEquals(promptBg, editor.backgroundColor) + assertEquals(promptBg, editor.scrollPane.background) + assertEquals(promptBg, editor.scrollPane.viewport.background) + assertEquals(promptBg, editor.contentComponent.background) } fun `test prompt editor grows when lines are added`() { @@ -501,6 +507,22 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals("hello", editor.document.text) } + fun `test prompt editor height sync skips bulk document updates`() { + val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) + val field = panel.defaultFocusedComponent as EditorTextField + + realize(panel, 260, 400) + val editor = field.getEditor(false)!! + WriteCommandAction.runWriteCommandAction(project) { + DocumentUtil.executeInBulk(editor.document, true) { + editor.document.insertString(0, "hello") + } + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals("hello", editor.document.text) + } + fun `test prompt editor highlights missing mention as wrong reference`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion()) val field = panel.defaultFocusedComponent as EditorTextField diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 95ff55bf948..8f369199893 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -22,6 +22,7 @@ import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.views.MessageToolbar import ai.kilocode.client.session.views.MessageView +import ai.kilocode.client.session.views.PromptAttachmentView import ai.kilocode.client.session.views.TextView import ai.kilocode.client.session.views.TurnView import ai.kilocode.client.session.views.base.PartView @@ -809,7 +810,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { layout(message) val box = promptBox(message) val point = SwingUtilities.convertPoint(box, Point(), message) - assertTrue("prompt box should be below attachment", point.y > 0) + val attachment = components(message).filterIsInstance().single() + val attachmentPoint = SwingUtilities.convertPoint(attachment, Point(), box) + assertTrue("attachment should be inside prompt box below prompt text", attachmentPoint.y > 0) val image = BufferedImage(message.width, message.height, BufferedImage.TYPE_INT_ARGB) val graphics = image.createGraphics() @@ -818,7 +821,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { val line = SessionUiStyle.View.Outline.color().rgb assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb) - assertFalse(line == Color(image.getRGB(point.x + box.width / 2, 0), true).rgb) + assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y + box.height - 1), true).rgb) } fun `test created ContentDelta is not double applied`() { @@ -1635,7 +1638,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { } private fun promptBox(root: MessageView): Component { - return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView } + return components(root).first { it.parent != root && it is JPanel && it.components.any { child -> child is TextView } } } private fun components(root: Component): List { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index dee8f91c9ff..e85eb69e4cf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -3,7 +3,9 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.attachment.AttachmentCard +import ai.kilocode.client.session.ui.attachment.AttachmentChip import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.session.views.AttachmentView import ai.kilocode.client.session.views.PromptAttachmentView import ai.kilocode.client.session.views.tool.ReadToolView @@ -20,6 +22,7 @@ import ai.kilocode.rpc.dto.PartSourceTextDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.Container import java.awt.event.MouseEvent @@ -270,16 +273,21 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val attachment = msg.part("f1")!! val other = msg.part("f2")!! - assertSame(msg, attachment.parent) + assertNotSame(msg, attachment.parent) assertSame(attachment, other) assertEquals(listOf("p1", "f1", "f2"), msg.partIds()) - assertEquals(1, msg.components.filterIsInstance().size) - assertEquals(2, findAll(attachment, AttachmentCard::class.java).size) + assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size) + assertEquals(1, findAll(attachment, AttachmentCard::class.java).size) + assertEquals(1, findAll(attachment, AttachmentChip::class.java).size) val cards = findAll(attachment, AttachmentCard::class.java) for (card in cards) { card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) } + val chips = findAll(attachment, AttachmentChip::class.java) + for (chip in chips) { + chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + } assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened) } @@ -356,7 +364,26 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val view = panel.findMessage("u1")!!.part("f1") assertTrue(view is PromptAttachmentView) - assertNotNull(find(view!!, AttachmentCard::class.java)) + assertNotNull(find(view!!, AttachmentChip::class.java)) + } + + fun `test source less file selection renders filename range chip`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "file:///tmp/HvJwtFilter.java?start=12&end=40", + filename = "HvJwtFilter.java", + )) + + val view = panel.findMessage("u1")!!.part("f1")!! + val chip = find(view, AttachmentChip::class.java) + + assertNotNull(chip) + assertTrue(findAll(chip!!, JBLabel::class.java).any { it.text.contains("HvJwtFilter.java:12-40") }) } fun `test source backed image attachment still renders in prompt strip`() { @@ -400,7 +427,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertNull(msg.part("p2")) assertEquals(listOf("p1", "f1"), msg.partIds()) assertTrue(msg.part("p1") is TextView) - assertEquals(1, msg.components.filterIsInstance().size) + assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size) } fun `test prompt text panel is removed when content becomes empty`() { @@ -441,11 +468,9 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy) assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy) assertEquals(0, view.insets.top) - assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), view.insets.bottom) + assertEquals(UiStyle.Gap.sm(), view.insets.bottom) assertEquals( - JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) + - pane.horizontalScrollBar.preferredSize.height + - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) + UiStyle.Gap.sm(), height, ) @@ -497,8 +522,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() { ), ) - val card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!! - card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + val chip = find(item.findMessage("u1")!!.part("f1")!!, AttachmentChip::class.java)!! + chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt new file mode 100644 index 00000000000..cf1e3408db8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/MessageViewTest.kt @@ -0,0 +1,48 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.SwingUtilities + +class MessageViewTest : BasePlatformTestCase() { + // A user message can carry both a prompt bubble (wrapped, lower component index) and a tool + // view added after it. Replacing that tool (e.g. a completed question) must reuse the tool's + // own slot, not the prompt wrap's lower index, or the replacement jumps above the bubble. + fun `test replacing a tool view keeps it below the prompt bubble`() { + val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0))) + val view = MessageView(msg, openFile = { _, _ -> }) + + val text = Text("p1").also { it.content.append("do the thing") } + msg.parts["p1"] = text + view.upsertPart(text) + + val tool = Tool("t1", "question", ToolKind.GENERIC).also { + it.state = ToolExecState.RUNNING + it.input = mapOf("questions" to """[{"question":"Proceed?"}]""") + } + msg.parts["t1"] = tool + view.upsertPart(tool) + + tool.state = ToolExecState.COMPLETED + tool.metadata = mapOf("answers" to """[["Yes"]]""") + view.upsertPart(tool) + + val result = view.part("t1") + val prompt = view.part("p1") + assertNotNull(result) + assertNotNull(prompt) + assertTrue(result is QuestionResultView) + val children = view.components.toList() + val wrapIndex = children.indexOfFirst { SwingUtilities.isDescendingFrom(prompt, it) } + val resultIndex = children.indexOf(result) + assertTrue("prompt bubble is a direct child", wrapIndex >= 0) + assertTrue("question result stays below the prompt bubble", resultIndex > wrapIndex) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt new file mode 100644 index 00000000000..27ad1470af7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PromptAttachmentViewTest.kt @@ -0,0 +1,72 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.ui.attachment.AttachmentCardItem +import ai.kilocode.client.session.ui.attachment.AttachmentChip +import ai.kilocode.client.ui.UiStyle +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import java.awt.Container + +class PromptAttachmentViewTest : BasePlatformTestCase() { + // The attachment strip should line up with the prompt text horizontally and keep only a + // small standard bottom inset below the selection reference. + fun `test attachment padding matches prompt text with small bottom inset`() { + val prompt = PromptView(Text("p1")).insets + val attach = PromptAttachmentView("m1") {}.insets + + assertEquals(prompt.left, attach.left) + assertEquals(prompt.right, attach.right) + assertEquals(0, attach.top) + assertEquals(UiStyle.Gap.sm(), attach.bottom) + } + + fun `test attachment scroll pane has no border line`() { + val scroll = PromptAttachmentView("m1") {}.scrollPane() + + assertEquals(0, scroll.border.getBorderInsets(scroll).top) + assertEquals(0, scroll.border.getBorderInsets(scroll).left) + assertEquals(0, scroll.border.getBorderInsets(scroll).bottom) + assertEquals(0, scroll.border.getBorderInsets(scroll).right) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).top) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).left) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).bottom) + assertEquals(0, scroll.viewportBorder.getBorderInsets(scroll).right) + } + + // With the outline removed, the chip owns no internal padding; alignment comes from the + // container so the chip content sits flush against the prompt-matching insets. + fun `test attachment chip has no outline padding`() { + val chip = AttachmentChip( + AttachmentCardItem("HvJwtFilter.java", "text/plain", "file:///HvJwtFilter.java"), + file = true, + startLine = 40, + endLine = 42, + ).insets + + assertEquals(0, chip.left) + assertEquals(0, chip.right) + assertEquals(0, chip.top) + assertEquals(0, chip.bottom) + } + + fun `test attachment chip uses file link underline style`() { + val chip = AttachmentChip( + AttachmentCardItem("HvJwtFilter.java", "text/plain", "file:///HvJwtFilter.java"), + file = true, + startLine = 40, + endLine = 42, + ) + val label = components(chip).filterIsInstance().single() + + assertTrue(label.text.contains("HvJwtFilter.java:40-42")) + } + + private fun components(root: Container): List = buildList { + fun visit(comp: java.awt.Component) { + add(comp) + if (comp is Container) comp.components.forEach { visit(it) } + } + visit(root) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 14bfaea5269..4f6d118dfec 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -229,14 +229,14 @@ class TextViewTest : BasePlatformTestCase() { assertEquals(style.editorForeground, view.md.foreground) } - fun `test prompt view uses transcript font and editor background`() { + fun `test prompt view uses transcript font and prompt background`() { val style = SessionEditorStyle.create(family = "Courier New", size = 23) val view = PromptView(Text("p1")) view.applyStyle(style) assertEquals(style.transcriptFont, view.md.font) - assertEquals(style.editorBackground, view.md.background) + assertEquals(SessionUiStyle.View.Prompt.bgColor(style), view.md.background) assertFalse(view.contentOpaque()) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt index 8fc6dd6179d..d7c285627fa 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsStateTest.kt @@ -3,15 +3,16 @@ package ai.kilocode.client.settings.context import ai.kilocode.rpc.dto.CompactionConfigDto import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.WatcherConfigDto -import kotlin.test.Test +import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -class ContextSettingsStateTest { - @Test - fun `draft reads context config`() { +// Extends BasePlatformTestCase so the IntelliJ Application is initialized: ContextDraft's default +// editor value reads a PropertiesComponent app service, which is null in a plain unit test. +class ContextSettingsStateTest : BasePlatformTestCase() { + fun `test draft reads context config`() { val draft = contextDraft(ConfigDto( watcher = WatcherConfigDto(ignore = listOf("**/dist/**")), compaction = CompactionConfigDto(auto = true, threshold_percent = 75.0, prune = true), @@ -23,15 +24,13 @@ class ContextSettingsStateTest { assertEquals(listOf("**/dist/**"), draft.ignore) } - @Test - fun `unchanged draft emits no patch`() { + fun `test unchanged draft emits no patch`() { val draft = ContextDraft(auto = true, threshold = "75", prune = false, ignore = listOf("tmp/**")) assertEquals(false, patch(draft, draft)?.let(::changed)) } - @Test - fun `boolean false values are emitted`() { + fun `test boolean false values are emitted`() { val from = ContextDraft(auto = true, prune = true) val to = ContextDraft(auto = false, prune = false) val patch = patch(from, to) @@ -40,8 +39,7 @@ class ContextSettingsStateTest { assertEquals(false, patch?.compaction?.prune) } - @Test - fun `threshold set and clear use explicit semantics`() { + fun `test threshold set and clear use explicit semantics`() { val from = ContextDraft(threshold = "") val set = ContextDraft(threshold = "80") val clear = ContextDraft(threshold = "") @@ -51,16 +49,14 @@ class ContextSettingsStateTest { assertNull(patch(set, clear)?.compaction?.threshold_percent) } - @Test - fun `watcher empty list is emitted`() { + fun `test watcher empty list is emitted`() { val from = ContextDraft(ignore = listOf("**/dist/**")) val to = ContextDraft(ignore = emptyList()) - assertEquals(emptyList(), patch(from, to)?.watcher?.ignore) + assertEquals(emptyList(), patch(from, to)?.watcher?.ignore) } - @Test - fun `invalid threshold prevents patch without looking like no changes`() { + fun `test invalid threshold prevents patch without looking like no changes`() { val from = ContextDraft(threshold = "50") val to = ContextDraft(auto = true, threshold = "101", prune = true, ignore = listOf("tmp/**")) @@ -68,8 +64,7 @@ class ContextSettingsStateTest { assertNull(patch(from, to)) } - @Test - fun `saved match normalizes threshold formatting`() { + fun `test saved match normalizes threshold formatting`() { assertTrue(savedMatches(ContextDraft(threshold = "75"), ContextDraft(threshold = "75.0"))) assertFalse(savedMatches(ContextDraft(threshold = "75"), ContextDraft(threshold = "76"))) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt index 80c5a2e1c4d..44337dd149a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/context/ContextSettingsUiTest.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.settings.context import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.plugin.KiloPluginSettings +import ai.kilocode.client.settings.base.SettingsRow import ai.kilocode.client.settings.base.SettingsToggle import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.testing.FakeAppRpcApi @@ -69,6 +71,7 @@ class ContextSettingsUiTest : BasePlatformTestCase() { ui = null uiScope.cancel() appScope.cancel() + KiloPluginSettings.unsetAutoEditorContext() } finally { super.tearDown() } @@ -253,21 +256,43 @@ class ContextSettingsUiTest : BasePlatformTestCase() { } } - fun `test controls are disabled during pending save`() { + fun `test controls are disabled during pending save except local editor toggle`() { val panel = requireUi() rpc.configUpdateGate = CompletableDeferred() edt { threshold(panel).text = "80" panel.applyDraft() - assertTrue(components(panel).filterIsInstance().all { !it.isEnabled }) + val editor = editorToggle(panel) + val cli = components(panel).filterIsInstance().filter { it !== editor } + assertTrue(cli.all { !it.isEnabled }) assertFalse(threshold(panel).isEnabled) + assertTrue(editor.isEnabled) } rpc.configUpdateGate?.complete(Unit) flushUntil { rpc.configPatches.isNotEmpty() } } + fun `test editor context toggle marks modified and applies without a config patch`() { + val panel = requireUi() + assertTrue(KiloPluginSettings.getAutoEditorContext()) + + edt { + val editor = editorToggle(panel) + assertTrue(editor.isEnabled) + editor.doClick() + assertTrue(panel.modified()) + } + + assertTrue(KiloPluginSettings.getAutoEditorContext()) + edt { panel.applyDraft() } + assertFalse(KiloPluginSettings.getAutoEditorContext()) + edt { UIUtil.dispatchAllInvocationEvents() } + assertFalse(edt { panel.modified() }) + assertTrue(rpc.configPatches.isEmpty()) + } + private fun requireUi(): ContextSettingsUi = requireNotNull(ui) private fun threshold(panel: ContextSettingsUi): JBTextField = components(panel) @@ -284,6 +309,14 @@ class ContextSettingsUiTest : BasePlatformTestCase() { .filterIsInstance() .single { it.toolTipText == tip } + private fun editorToggle(panel: ContextSettingsUi): SettingsToggle { + val label = components(panel).filterIsInstance() + .first { it.text == "Auto-Include Editor Context" } + var row: Container? = label.parent + while (row != null && row !is SettingsRow) row = row.parent + return components(requireNotNull(row)).filterIsInstance().single() + } + private fun edt(block: () -> T): T { var result: T? = null ApplicationManager.getApplication().invokeAndWait { result = block() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 327566bf9c3..da98aa58b5a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -111,10 +111,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return branchName } - override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { + override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean { assertNotEdt("openFile") opened.add(path) - openedFiles.add(Opened(path, line, column)) + openedFiles.add(Opened(path, line, column, endLine)) return openResult } @@ -150,5 +150,5 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return openResult } - data class Opened(val path: String, val line: Int?, val column: Int?) + data class Opened(val path: String, val line: Int?, val column: Int?, val endLine: Int? = null) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 588c0c9d0e6..c6e1f3cc34e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -72,10 +72,22 @@ object ChatLogSummary { prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" } model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" } prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" } + prompt.editorContext?.let { ctx -> + out += "editorContext=true" + ctx.activeFile?.let { file -> out += editorFile("activeFile", file) } + ctx.openTabs?.size?.takeIf { it > 0 }?.let { out += "openTabs=$it" } + ctx.visibleFiles?.size?.takeIf { it > 0 }?.let { out += "visibleFiles=$it" } + ctx.shell?.takeIf { it.isNotBlank() }?.let { out += "shell=$it" } + } preview(text)?.let { out += "preview=\"$it\"" } return out.joinToString(" ") } + private fun editorFile(key: String, file: String): String { + if (mode() == Mode.OFF) return "${key}Hash=${hash(file)}" + return "$key=\"${clean(file)}\"" + } + fun history(items: List): String { val out = mutableListOf() val parts = items.sumOf { it.parts.size } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f609a796da1..bebd2c38690 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -67,7 +67,7 @@ interface KiloWorkspaceRpcApi : RemoteApi { suspend fun branchName(directory: String): String? /** Open an absolute backend file path in the IDE. */ - suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean + suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean /** Resolve the editable local config target. */ suspend fun localConfigTarget(directory: String): ConfigTargetDto diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index ecf0da6a410..5e21bfdd98c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -122,6 +122,17 @@ data class PromptDto( val agent: String? = null, val variant: String? = null, val noReply: Boolean? = null, + val editorContext: EditorContextDto? = null, +) + +@Serializable +data class EditorContextDto( + val directory: String? = null, + val worktree: String? = null, + val visibleFiles: List? = null, + val openTabs: List? = null, + val activeFile: String? = null, + val shell: String? = null, ) @Serializable