diff --git a/.changeset/jetbrains-prompt-completion.md b/.changeset/jetbrains-prompt-completion.md new file mode 100644 index 0000000000..552dfa7ccc --- /dev/null +++ b/.changeset/jetbrains-prompt-completion.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Add `/` slash commands and `@` file/git-changes mentions to the JetBrains chat prompt with native completion. 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 c9177acbb0..a1e9b7f158 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 @@ -203,8 +203,6 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi { } } - override suspend fun terminalOutput(directory: String): String? = null - override suspend fun gitChanges(directory: String): String? = withContext(Dispatchers.IO) { val base = file(clean(directory) ?: directory) ?: return@withContext null if (!gitAvailable(base)) return@withContext null 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 b88966e5f0..ecfa1de8b1 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 @@ -136,15 +136,6 @@ class KiloWorkspaceService internal constructor( } } - suspend fun terminalOutput(directory: String): String? { - return try { - call { terminalOutput(directory) } - } catch (e: Exception) { - LOG.warn("terminal output lookup failed for directory=$directory", e) - null - } - } - suspend fun gitChanges(directory: String): String? { return try { call { gitChanges(directory) } 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 790a215c24..444022cbf1 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 @@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.migration.MigrationUiState import ai.kilocode.client.migration.ui.MigrationOverlayPanel +import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.FileAttachment import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState @@ -20,6 +21,8 @@ import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.prompt.gitChangesPart +import ai.kilocode.client.session.ui.prompt.mentionFileParts import ai.kilocode.client.session.ui.account.SessionAccountOverlay import ai.kilocode.client.session.ui.SessionDropOverlay import ai.kilocode.client.session.ui.SessionRootPanel @@ -47,8 +50,6 @@ import ai.kilocode.client.vfs.KiloVfsManager import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.dto.PromptDto import ai.kilocode.rpc.dto.PromptPartDto -import ai.kilocode.rpc.dto.PartSourceDto -import ai.kilocode.rpc.dto.PartSourceTextDto import com.intellij.util.ui.JBUI import ai.kilocode.log.KiloLog import com.intellij.ide.BrowserUtil @@ -67,6 +68,7 @@ import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurableWithId import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project +import com.intellij.openapi.progress.runBlockingCancellable import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.registry.Registry import com.intellij.util.concurrency.annotations.RequiresEdt @@ -74,13 +76,10 @@ import java.util.function.Predicate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.awt.BorderLayout import java.awt.event.HierarchyEvent import java.net.URI -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import java.nio.file.Path import javax.swing.JComponent import javax.swing.JPanel @@ -171,6 +170,7 @@ class SessionUi( private lateinit var connection: ConnectionPanel private lateinit var prompt: PromptPanel + private lateinit var completion: KiloPromptCompletionProvider private lateinit var load: LoadingPanel private lateinit var migrationOverlay: MigrationOverlayPanel private var empty: EmptySessionPanel? = null @@ -332,7 +332,7 @@ class SessionUi( scroll = SessionScroll(root, sessionContent, messageBody, blankBody) connection = ConnectionPanel(this, controller) - val completion = KiloPromptCompletionProvider( + completion = KiloPromptCompletionProvider( workspace = workspace, service = workspaces, actions = slashActions(), @@ -590,7 +590,7 @@ class SessionUi( } prompt.clear() val follow = scroll.atBottom() - val command = serverCommand(text) + val command = completion.serverCommand(text) if (command != null) controller.command(command.first, command.second, files) else controller.prompt(text, files) scroll.followBottom(follow) @@ -599,106 +599,49 @@ class SessionUi( private fun slashActions(): List = listOf( KiloPromptCompletionProvider.SlashAction( "new", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.new"), + KiloBundle.message("prompt.slash.new"), ) { manager?.newSession() }, KiloPromptCompletionProvider.SlashAction( "sessions", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.sessions"), + KiloBundle.message("prompt.slash.sessions"), listOf("history", "resume"), ) { manager?.showHistory() }, KiloPromptCompletionProvider.SlashAction( "models", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.models"), + KiloBundle.message("prompt.slash.models"), ) { prompt.model.open() }, KiloPromptCompletionProvider.SlashAction( "agents", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.agents"), + KiloBundle.message("prompt.slash.agents"), listOf("modes"), ) { prompt.mode.open() }, KiloPromptCompletionProvider.SlashAction( "variant", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.variant"), + KiloBundle.message("prompt.slash.variant"), listOf("reasoning"), ) { prompt.reasoning.open() }, KiloPromptCompletionProvider.SlashAction( "compact", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.compact"), + KiloBundle.message("prompt.slash.compact"), listOf("smol"), ) { controller.compact() }, KiloPromptCompletionProvider.SlashAction( "settings", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.settings"), + KiloBundle.message("prompt.slash.settings"), ) { openKiloSettings() }, KiloPromptCompletionProvider.SlashAction( "help", - ai.kilocode.client.plugin.KiloBundle.message("prompt.slash.help"), + KiloBundle.message("prompt.slash.help"), ) { BrowserUtil.browse("https://kilo.ai/docs") }, ) - private fun clientCommandNames(): Set = setOf( - "new", - "sessions", - "models", - "agents", - "variant", - "compact", - "settings", - "help", - ) - - private fun serverCommand(text: String): Pair? { - val raw = text.trimStart() - if (!raw.startsWith('/')) return null - val name = raw.drop(1).takeWhile { !it.isWhitespace() } - if (name.isBlank()) return null - if (name in clientCommandNames()) return null - if (workspace.state.value.commands.none { it.name == name }) return null - return name to raw.drop(name.length + 1).trimStart() - } - private fun mentionParts(text: String, paths: Set): List = buildList { - paths.filter { text.contains("@$it") }.forEach { path -> - val token = "@$path" - val start = text.indexOf(token).takeIf { it >= 0 } ?: return@forEach - val target = runCatching { - val item = Path.of(path) - if (item.isAbsolute) item else Path.of(workspace.directory).resolve(item).normalize() - }.getOrNull() ?: return@forEach - add(PromptPartDto( - type = "file", - mime = "text/plain", - url = target.toUri().toString(), - filename = target.fileName?.toString(), - source = source("file", token, start, path = path), - )) - } - val terminal = text.indexOf("@terminal") - if (terminal >= 0) { - runBlocking { workspaces.terminalOutput(workspace.directory) } - ?.takeIf { it.isNotBlank() } - ?.let { add(dataPart("terminal-output.txt", it, source("resource", "@terminal", terminal, uri = "terminal"))) } - } - val git = text.indexOf("@git-changes") - if (git >= 0) { - runBlocking { workspaces.gitChanges(workspace.directory) } - ?.takeIf { it.isNotBlank() } - ?.let { add(dataPart("git-changes.txt", it, source("resource", "@git-changes", git, uri = "git-changes"))) } + addAll(mentionFileParts(text, paths, workspace.directory)) + if (text.contains("@git-changes")) { + gitChangesPart(text, runBlockingCancellable { workspaces.gitChanges(workspace.directory) })?.let(::add) } } - private fun dataPart(name: String, text: String, source: PartSourceDto? = null): PromptPartDto { - val data = URLEncoder.encode(text, StandardCharsets.UTF_8).replace("+", "%20") - return PromptPartDto(type = "file", mime = "text/plain", url = "data:text/plain;charset=utf-8,$data", filename = name, source = source) - } - - private fun source(type: String, token: String, start: Int, path: String? = null, uri: String? = null) = PartSourceDto( - type = type, - text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()), - path = path, - uri = uri, - clientName = if (type == "resource") "jetbrains" else null, - ) - private fun openFile(path: String) { cs.launch { workspaces.openPath(workspace.directory, path) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt index bd2e1c6798..83ff247739 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProvider.kt @@ -51,6 +51,16 @@ class KiloPromptCompletionProvider( fun clientNames(): Set = actions.mapTo(mutableSetOf()) { it.name } + fun serverCommand(text: String): Pair? { + val raw = text.trimStart() + if (!raw.startsWith('/')) return null + val name = raw.drop(1).takeWhile { !it.isWhitespace() } + if (name.isBlank()) return null + if (name in clientNames()) return null + if (workspace.state.value.commands.none { it.name == name }) return null + return name to raw.drop(name.length + 1).trimStart() + } + fun highlights(text: String): List = buildList { val command = text.takeIf { it.startsWith('/') } ?.drop(1) @@ -62,7 +72,7 @@ class KiloPromptCompletionProvider( } val ranges = mutableListOf() - val values = (mentionPaths() + setOf("terminal", "git-changes")) + val values = (mentionPaths() + setOf("git-changes")) .filter { it.isNotBlank() } .sortedByDescending { it.length } values.forEach { value -> @@ -118,9 +128,6 @@ class KiloPromptCompletionProvider( if ("git-changes".startsWith(prefix, ignoreCase = true) && search.git) { out.addElement(special("git-changes", KiloBundle.message("prompt.mention.gitChanges"))) } - if ("terminal".startsWith(prefix, ignoreCase = true) && search.terminal) { - out.addElement(special("terminal", KiloBundle.message("prompt.mention.terminal"))) - } if (search.indexing) { val msg = KiloBundle.message("prompt.mention.indexing") result.addLookupAdvertisement(msg) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt new file mode 100644 index 0000000000..9975320c9c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionParts.kt @@ -0,0 +1,49 @@ +package ai.kilocode.client.session.ui.prompt + +import ai.kilocode.rpc.dto.PartSourceDto +import ai.kilocode.rpc.dto.PartSourceTextDto +import ai.kilocode.rpc.dto.PromptPartDto +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Path + +fun mentionFileParts(text: String, paths: Set, directory: String): List = buildList { + paths.filter { text.contains("@$it") }.forEach { path -> + val token = "@$path" + val start = text.indexOf(token).takeIf { it >= 0 } ?: return@forEach + val target = runCatching { + val item = Path.of(path) + if (item.isAbsolute) item else Path.of(directory).resolve(item).normalize() + }.getOrNull() ?: return@forEach + add(PromptPartDto( + type = "file", + mime = "text/plain", + url = target.toUri().toString(), + filename = target.fileName?.toString(), + source = source("file", token, start, path = path), + )) + } +} + +fun gitChangesPart(text: String, diff: String?): PromptPartDto? { + val raw = "@git-changes" + val start = text.indexOf(raw) + if (start < 0) return null + val end = start + raw.length + if (end < text.length && !text[end].isWhitespace()) return null + val value = diff?.takeIf { it.isNotBlank() } ?: return null + return dataPart("git-changes.txt", value, source("resource", raw, start, uri = "git-changes")) +} + +private fun dataPart(name: String, text: String, source: PartSourceDto? = null): PromptPartDto { + val data = URLEncoder.encode(text, StandardCharsets.UTF_8).replace("+", "%20") + return PromptPartDto(type = "file", mime = "text/plain", url = "data:text/plain;charset=utf-8,$data", filename = name, source = source) +} + +private fun source(type: String, token: String, start: Int, path: String? = null, uri: String? = null) = PartSourceDto( + type = type, + text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()), + path = path, + uri = uri, + clientName = if (type == "resource") "jetbrains" else null, +) 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 d7cd1df5dc..8f0e6deca2 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 @@ -512,6 +512,7 @@ class PromptPanel( } private fun showCompletion(ed: com.intellij.openapi.editor.Editor) { + // Uses IntelliJ impl/internal completion APIs; revisit on platform upgrades. CodeCompletionHandlerBase.createHandler(CompletionType.BASIC, true, false, true) .invokeCompletion(project, ed, 1) val lookup = LookupManager.getActiveLookup(ed) as? LookupImpl ?: return 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 1fdc29f245..3dfe38f295 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -163,7 +163,6 @@ prompt.action.enhance.failed=Failed to enhance prompt prompt.action.enhance.failed.description=The configured small model could not enhance this prompt. prompt.mention.indexing=Indexing project files. File mentions will be available soon. prompt.mention.gitChanges=Attach current git changes -prompt.mention.terminal=Attach terminal output prompt.slash.new=Start a new session prompt.slash.sessions=Open session history prompt.slash.models=Choose a model diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/CommandLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/CommandLifecycleTest.kt new file mode 100644 index 0000000000..21b9735083 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/CommandLifecycleTest.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.CommandDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto + +class CommandLifecycleTest : SessionControllerTestBase() { + + fun `test command creates new session and calls RPC`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy"))) + val m = controller() + + flush() + edt { m.command("deploy", "prod") } + flush() + + assertEquals(1, rpc.creates) + assertEquals(1, rpc.commands.size) + val call = rpc.commands.single() + assertEquals("ses_test", call.id) + assertEquals("/test", call.directory) + assertEquals("deploy", call.command) + assertEquals("prod", call.arguments) + } + + fun `test command reuses existing session`() { + val (m, _, _) = prompted() + val created = rpc.creates + + edt { m.command("deploy", "prod") } + flush() + + assertEquals(created, rpc.creates) + assertEquals("ses_test", rpc.commands.single().id) + } + + fun `test command records telemetry`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy"))) + val m = controller() + + flush() + edt { m.command("deploy", "prod") } + flush() + + val sent = appRpc.telemetry.single { it.event == "Conversation Send Clicked" } + assertEquals("command", sent.properties["source"]) + val message = appRpc.telemetry.single { it.event == "Conversation Message" } + assertEquals("command", message.properties["source"]) + } + + fun `test command errors set state and telemetry`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + projectRpc.state.value = workspaceReady().copy(commands = listOf(CommandDto("deploy"))) + rpc.commandThrows = IllegalStateException("boom") + val m = controller() + + flush() + edt { m.command("deploy", "prod") } + flush() + + assertTrue(m.model.state is SessionState.Error) + val event = appRpc.telemetry.single { it.event == "Session Error" } + assertEquals("command", event.properties["context"]) + } +} 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 a28ab03eb8..a45724db0d 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 @@ -221,12 +221,12 @@ class PromptPanelTest : BasePlatformTestCase() { val field = panel.defaultFocusedComponent as EditorTextField realize(panel, 260, 400) - field.text = "/new use @terminal and @unknown" + field.text = "/new use @git-changes and @unknown" UIUtil.dispatchAllInvocationEvents() val spans = spans(field) assertTrue(spans.contains("/new" to DefaultLanguageHighlighterColors.KEYWORD)) - assertTrue(spans.contains("@terminal" to DefaultLanguageHighlighterColors.METADATA)) + assertTrue(spans.contains("@git-changes" to DefaultLanguageHighlighterColors.METADATA)) assertFalse(spans.any { it.first == "@unknown" }) } @@ -235,7 +235,7 @@ class PromptPanelTest : BasePlatformTestCase() { val field = panel.defaultFocusedComponent as EditorTextField realize(panel, 260, 400) - field.text = "use @terminal" + field.text = "use @git-changes" UIUtil.dispatchAllInvocationEvents() assertEquals(1, field.getEditor(false)!!.markupModel.allHighlighters.size) @@ -251,7 +251,7 @@ class PromptPanelTest : BasePlatformTestCase() { realize(panel, 260, 400) repeat(50) { - field.text = if (it % 2 == 0) "/new @terminal" else "/new @git-changes" + field.text = if (it % 2 == 0) "/new @git-changes" else "/new @git-changes now" UIUtil.dispatchAllInvocationEvents() assertTrue(field.getEditor(false)!!.markupModel.allHighlighters.size <= 2) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt index f5c6193402..c6f28c2def 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/KiloPromptCompletionProviderTest.kt @@ -69,7 +69,7 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { } fun `test mention completion includes matching special items`() { - rpc.searchResult = FileSearchResultDto(git = true, terminal = true) + rpc.searchResult = FileSearchResultDto(git = true) complete("@git") @@ -103,13 +103,23 @@ class KiloPromptCompletionProviderTest : BasePlatformTestCase() { fun `test highlights special mentions without tracked paths`() { assertEquals( listOf( - KiloPromptCompletionProvider.Highlight(4, 13, KiloPromptCompletionProvider.HighlightKind.MENTION), - KiloPromptCompletionProvider.Highlight(18, 30, KiloPromptCompletionProvider.HighlightKind.MENTION), + KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.MENTION), ), - provider.highlights("use @terminal and @git-changes").sortedBy { it.start }, + provider.highlights("use @git-changes").sortedBy { it.start }, ) } + fun `test serverCommand routes only known server commands`() { + rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("deploy"))) + + waitFor { provider.serverCommand("/deploy x") != null } + + assertEquals("deploy" to "x", provider.serverCommand("/deploy x")) + assertNull(provider.serverCommand("/new")) + assertNull(provider.serverCommand("hi /deploy")) + assertNull(provider.serverCommand("/unknown")) + } + fun `test highlights tracked mentions longest first`() { addMention("src/a.ts", "@ts") addMention("src/a.tsx", "@tsx") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt new file mode 100644 index 0000000000..0dc6f824ab --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/prompt/PromptMentionPartsTest.kt @@ -0,0 +1,56 @@ +package ai.kilocode.client.session.ui.prompt + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.nio.file.Path + +class PromptMentionPartsTest : BasePlatformTestCase() { + + fun `test mentionFileParts builds file part for tracked relative path`() { + val parts = mentionFileParts("read @src/Main.kt", setOf("src/Main.kt"), "/repo") + + assertEquals(1, parts.size) + val part = parts.single() + assertEquals("file", part.type) + assertEquals("text/plain", part.mime) + assertEquals(Path.of("/repo/src/Main.kt").toUri().toString(), part.url) + assertEquals("Main.kt", part.filename) + assertEquals("file", part.source?.type) + assertEquals("src/Main.kt", part.source?.path) + assertEquals("@src/Main.kt", part.source?.text?.value) + assertEquals(5.0, part.source?.text?.start) + assertEquals(17.0, part.source?.text?.end) + } + + fun `test mentionFileParts ignores untracked path`() { + assertTrue(mentionFileParts("read @src/Main.kt", setOf("src/Other.kt"), "/repo").isEmpty()) + } + + fun `test mentionFileParts keeps absolute paths absolute`() { + val path = "/tmp/abs.txt" + val part = mentionFileParts("read @$path", setOf(path), "/repo").single() + + assertEquals(Path.of(path).toUri().toString(), part.url) + assertEquals("abs.txt", part.filename) + } + + fun `test gitChangesPart builds encoded data part`() { + val part = gitChangesPart("review @git-changes", "hello world+plus")!! + + assertEquals("file", part.type) + assertEquals("text/plain", part.mime) + assertEquals("git-changes.txt", part.filename) + assertEquals("data:text/plain;charset=utf-8,hello%20world%2Bplus", part.url) + assertEquals("resource", part.source?.type) + assertEquals("git-changes", part.source?.uri) + assertEquals("jetbrains", part.source?.clientName) + assertEquals("@git-changes", part.source?.text?.value) + assertEquals(7.0, part.source?.text?.start) + assertEquals(19.0, part.source?.text?.end) + } + + fun `test gitChangesPart ignores missing blank and non boundary matches`() { + assertNull(gitChangesPart("review @git-changes", null)) + assertNull(gitChangesPart("review @git-changes", " ")) + assertNull(gitChangesPart("review @git-changes-foo", "diff")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index a8c7c335d6..a7053469f2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -84,6 +84,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { var enhanced = "Enhanced prompt" var enhanceGate: CompletableDeferred? = null var enhanceThrows: Exception? = null + var commandThrows: Exception? = null val prompts = mutableListOf>() val commands = mutableListOf() val attachmentParts = mutableListOf() @@ -199,6 +200,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) { assertNotEdt("command") + commandThrows?.let { throw it } commands.add(CommandCall(id, directory, command, arguments, prompt)) } 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 5ebb479192..be1799b681 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 @@ -30,7 +30,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { var fileMatches = emptyList() var searchResult = FileSearchResultDto() var search: ((String) -> FileSearchResultDto)? = null - var terminalOutput: String? = null var gitChanges: String? = null var openResult = true var localConfigPath = "/test/.kilo/kilo.jsonc" @@ -82,11 +81,6 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return search?.invoke(query) ?: searchResult } - override suspend fun terminalOutput(directory: String): String? { - assertNotEdt("terminalOutput") - return terminalOutput - } - override suspend fun gitChanges(directory: String): String? { assertNotEdt("gitChanges") return gitChanges 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 4789970584..3c8c0d00da 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 @@ -50,9 +50,6 @@ interface KiloWorkspaceRpcApi : RemoteApi { /** Fuzzy file/folder search via the backend IDE index. */ suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto - /** Best-effort active terminal scrollback for @terminal mentions. */ - suspend fun terminalOutput(directory: String): String? - /** Current uncommitted git changes as a unified diff for @git-changes mentions. */ suspend fun gitChanges(directory: String): String? diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt index b58dd87033..8364b1b191 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorkspaceFileDto.kt @@ -13,6 +13,5 @@ data class WorkspaceFileDto( data class FileSearchResultDto( val indexing: Boolean = false, val files: List = emptyList(), - val terminal: Boolean = false, val git: Boolean = false, )