Merge pull request #11327 from Kilo-Org/basalt-dirt

feat(jetbrains): add slash commands and mention completion
This commit is contained in:
Kirill Kalishev
2026-06-23 14:00:45 -04:00
committed by GitHub
80 changed files with 3616 additions and 205 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Highlight rendered prompt file mentions and open them on click.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains file mention suggestions immediately for empty `@` mentions and keep the completion popup stable while typing quickly.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Hide raw file contents from mentioned files in JetBrains chat messages.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix @git-changes mentions causing JetBrains chat sessions to fail.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show file type icons and keep predefined mentions first in JetBrains mention completions.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Add `/` slash commands and `@` file/git-changes mentions to the JetBrains chat prompt with native completion.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a concise single-line hint in the JetBrains prompt placeholder.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix prompt undo/redo, clean mid-token mention completion, and show unresolved file mentions in the JetBrains chat prompt.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Attach hand-typed prompt file mentions when sending immediately.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Restore prompt focus after returning from session history in JetBrains.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support VS Code slash-command aliases in the JetBrains prompt.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep JetBrains prompt mention suggestions stable while typing fuzzy file matches.
@@ -168,7 +168,8 @@ class KiloBackendChatManager(
val raw = response.body?.string()
log.warn("prompt_async failed: HTTP $code")
raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async error=${ChatLogSummary.body(it)}" } }
throw RuntimeException("prompt_async failed: HTTP $code")
val detail = raw?.takeIf { it.isNotBlank() }?.let { ": ${ChatLogSummary.body(it)}" }.orEmpty()
throw RuntimeException("prompt_async failed: HTTP $code$detail")
}
log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async ok=true code=$code" }
}
@@ -180,6 +181,12 @@ class KiloBackendChatManager(
}
}
fun command(id: String, dir: String, command: String, args: String, prompt: PromptDto) {
log.info("${ChatLogSummary.sid(id)} kind=command command=$command args=${args.length} parts=${prompt.parts.size}")
val body = KiloCliDataParser.buildCommandJson(command, args, prompt)
post("/session/$id/command?directory=${encode(dir)}", body, "command", "${ChatLogSummary.sid(id)} kind=command command=$command")
}
// ------ abort ------
fun abort(id: String, dir: String) {
@@ -23,6 +23,8 @@ import ai.kilocode.rpc.dto.ModelLimitDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionFileDiffDto
import ai.kilocode.rpc.dto.PermissionReplyDto
@@ -586,6 +588,7 @@ object KiloCliDataParser {
part.mime?.let { fields += "\"mime\":${escape(it)}" }
part.url?.let { fields += "\"url\":${escape(it)}" }
part.filename?.let { fields += "\"filename\":${escape(it)}" }
part.source?.let { fields += "\"source\":${sourceJson(it)}" }
return "{${fields.joinToString(",")}}"
}
fields += "\"text\":${escape(part.text.orEmpty())}"
@@ -598,6 +601,24 @@ object KiloCliDataParser {
fun buildSummarizeJson(model: ModelSelectionDto): String =
"""{"providerID":${escape(model.providerID)},"modelID":${escape(model.modelID)}}"""
fun buildCommandJson(command: String, args: String, prompt: PromptDto): String {
val fields = mutableListOf(
"\"command\":${escape(command)}",
"\"arguments\":${escape(args)}",
)
prompt.agent?.let { fields += "\"agent\":${escape(it)}" }
prompt.variant?.let { fields += "\"variant\":${escape(it)}" }
val pid = prompt.providerID
val mid = prompt.modelID
if (pid != null && mid != null) {
val model = "$pid/$mid"
fields += "\"model\":${escape(model)}"
}
val parts = prompt.parts.filter { it.type == "file" }.joinToString(",") { buildPromptPartJson(it) }
if (parts.isNotEmpty()) fields += "\"parts\":[$parts]"
return "{${fields.joinToString(",")}}"
}
/**
* Build the partial JSON body for `PATCH /global/config`.
*/
@@ -772,6 +793,8 @@ object KiloCliDataParser {
mime = obj.str("mime"),
url = obj.str("url"),
filename = obj.str("filename"),
synthetic = obj.flagOrNull("synthetic"),
source = parseSource(obj["source"]),
tool = obj.str("tool"),
callID = obj.str("callID"),
state = state?.str("status"),
@@ -799,6 +822,37 @@ object KiloCliDataParser {
return READ_TOOL_PATH.containsMatchIn(line)
}
private fun parseSource(raw: JsonElement?): PartSourceDto? {
val obj = raw.obj() ?: return null
val type = obj.str("type") ?: return null
val text = obj["text"].obj() ?: return null
val value = text.str("value") ?: return null
val start = text.num("start") ?: return null
val end = text.num("end") ?: return null
return PartSourceDto(
type = type,
text = PartSourceTextDto(value = value, start = start, end = end),
path = obj.str("path"),
clientName = obj.str("clientName"),
uri = obj.str("uri"),
name = obj.str("name"),
kind = obj.long("kind")?.safeInt(),
)
}
private fun sourceJson(source: PartSourceDto): String {
val fields = mutableListOf(
"\"type\":${escape(source.type)}",
"\"text\":{\"value\":${escape(source.text.value)},\"start\":${source.text.start},\"end\":${source.text.end}}",
)
source.path?.let { fields += "\"path\":${escape(it)}" }
source.clientName?.let { fields += "\"clientName\":${escape(it)}" }
source.uri?.let { fields += "\"uri\":${escape(it)}" }
source.name?.let { fields += "\"name\":${escape(it)}" }
source.kind?.let { fields += "\"kind\":$it" }
return "{${fields.joinToString(",")}}"
}
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
return parseTodosOrNull(raw) ?: emptyList()
}
@@ -1293,6 +1347,11 @@ private fun JsonObject.flag(key: String, default: Boolean): Boolean {
return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull() ?: default
}
private fun JsonObject.flagOrNull(key: String): Boolean? {
val prim = this[key]?.jsonPrimitive ?: return null
return prim.booleanOrNull ?: prim.contentOrNull?.toBooleanStrictOrNull()
}
private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt()
private fun JsonObject?.map(key: String): Map<String, String> {
@@ -110,6 +110,12 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
chat.prompt(id, directory, prompt)
}
override suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto) {
app.requireReady()
LOG.info("command RPC: session=$id, dir=$directory, command=$command, parts=${prompt.parts.size}")
chat.command(id, directory, command, arguments, prompt)
}
override suspend fun abort(id: String, directory: String) =
ready { chat.abort(id, directory) }
@@ -1,5 +1,3 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloAppState
@@ -16,19 +14,36 @@ import ai.kilocode.log.KiloLog
import ai.kilocode.jetbrains.api.model.Agent
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.process.CapturingProcessHandler
import com.intellij.ide.actions.searcheverywhere.FoundItemDescriptor
import com.intellij.ide.util.gotoByName.ChooseByNameInScopeItemProvider
import com.intellij.ide.util.gotoByName.ChooseByNamePopup
import com.intellij.ide.util.gotoByName.ChooseByNameViewModel
import com.intellij.ide.util.gotoByName.GotoFileModel
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.application.readAction
import com.intellij.openapi.components.service
import com.intellij.openapi.progress.EmptyProgressIndicator
import com.intellij.openapi.project.DumbService
import com.intellij.openapi.project.IndexNotReadyException
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.roots.ProjectFileIndex
import com.intellij.openapi.util.io.FileUtil
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.navigation.NavigationItem
import com.intellij.psi.PsiFileSystemItem
import com.intellij.psi.search.GlobalSearchScope
import com.intellij.util.indexing.FindSymbolParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -46,6 +61,7 @@ import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
/**
@@ -63,6 +79,8 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
private val LEGACY = listOf("opencode.jsonc", "opencode.json")
private val GLOBAL = MODERN + LEGACY + "config.json"
private val LOCAL_DIRS = listOf(".kilo", ".kilocode", ".opencode")
private const val SEARCH_CAP = 2_000
private const val DIFF_CAP = 200_000
private val CONFIG = """{
"${'$'}schema": "$SCHEMA"
}
@@ -71,6 +89,8 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
private val app: KiloBackendAppService get() = service()
private val gitCache = ConcurrentHashMap<String, Boolean>()
private val manager: KiloBackendWorkspaceManager
get() = app.workspaces
@@ -173,6 +193,31 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
return found.values.toList()
}
override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto {
val base = file(clean(directory) ?: directory) ?: return FileSearchResultDto()
val git = withContext(Dispatchers.IO) { gitAvailable(base) }
val project = project(base) ?: return FileSearchResultDto(git = git)
if (DumbService.getInstance(project).isDumb) return FileSearchResultDto(indexing = true, git = git)
return try {
val files = readAction { search(project, base, query, limit.coerceIn(1, 200)) }
FileSearchResultDto(files = files, git = git)
} catch (e: IndexNotReadyException) {
FileSearchResultDto(indexing = true, git = git)
} catch (e: LinkageError) {
LOG.warn("file search API unavailable; returning no suggestions", e)
FileSearchResultDto(git = git)
}
}
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
val unstaged = git(base, "diff")
val staged = git(base, "diff", "--staged")
val text = listOf(unstaged, staged).filter { it.isNotBlank() }.joinToString("\n")
text.takeIf { it.isNotBlank() }?.take(DIFF_CAP)
}
override suspend fun openFile(path: String): Boolean {
val item = clean(path) ?: return false
val target = file(item)?.takeIf { it.isAbsolute } ?: return false
@@ -240,15 +285,9 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
}
private fun clean(path: String): String? {
val raw = path.trim().takeIf { it.isNotBlank() } ?: return null
return try {
val cut = raw.substringBefore('#').substringBefore('?')
val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8)
Path.of(decoded.replace('\\', '/')).normalize().toString()
} catch (e: Exception) {
LOG.debug { "Failed to normalize workspace file path: $path (${e.message})" }
null
}
val result = normalizeWorkspacePath(path)
if (result == null && path.isNotBlank()) LOG.debug { "Failed to normalize workspace file path: $path" }
return result
}
private fun file(path: String): Path? = try {
@@ -273,6 +312,91 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
} ?: projects.firstOrNull()
}
// Uses the IDE Go-to-File engine (com.intellij.ide.util.gotoByName.*). These are public but
// unstable lang-impl classes (not @ApiStatus.Internal) -- the same engine behind Search Everywhere,
// chosen for proven large-repo performance. searchFiles() degrades gracefully on LinkageError.
@Suppress("UnstableApiUsage")
private fun search(project: Project, base: Path, query: String, limit: Int): List<WorkspaceFileDto> {
val text = query.trim()
if (text.isBlank()) return roots(project, base, limit)
val scope = GlobalSearchScope.projectScope(project)
val model = object : GotoFileModel(project) {
override fun acceptItem(item: NavigationItem): Boolean {
val psi = item as? PsiFileSystemItem ?: return false
val path = file(psi.virtualFile.path) ?: return false
return path.startsWith(base) && super.acceptItem(item)
}
override fun loadInitialCheckBoxState(): Boolean = false
override fun saveInitialCheckBoxState(state: Boolean) {}
}
val view = object : ChooseByNameViewModel {
override fun getProject(): Project = project
override fun getModel() = model
override fun isSearchInAnyPlace(): Boolean = model.useMiddleMatching()
override fun transformPattern(pattern: String): String = ChooseByNamePopup.getTransformedPattern(pattern, model)
override fun canShowListForEmptyPattern(): Boolean = false
override fun getMaximumListSizeLimit(): Int = limit
}
val provider = model.getItemProvider(null)
val params = FindSymbolParameters.wrap(text, scope)
val found = mutableListOf<FoundItemDescriptor<*>>()
val indicator = EmptyProgressIndicator()
if (provider is ChooseByNameInScopeItemProvider) {
provider.filterElementsWithWeights(view, params, indicator) { item ->
found += item
found.size < SEARCH_CAP
}
} else {
provider.filterElements(view, text, false, indicator) { item ->
found += FoundItemDescriptor(item, 0)
found.size < SEARCH_CAP
}
}
return found.asSequence()
.sortedByDescending { it.weight }
.mapNotNull { item -> (item.item as? PsiFileSystemItem)?.virtualFile }
.mapNotNull { vf -> fileDto(base, vf) }
.distinctBy { it.path }
.take(limit)
.toList()
}
private fun roots(project: Project, base: Path, limit: Int): List<WorkspaceFileDto> {
val root = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(base) ?: return emptyList()
val index = ProjectFileIndex.getInstance(project)
return root.children.asSequence()
.filter { it.name != ".git" }
.filterNot { index.isExcluded(it) }
.mapNotNull { fileDto(base, it) }
.sortedWith(
compareByDescending<WorkspaceFileDto> { it.directory }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
.take(limit)
.toList()
}
private fun fileDto(base: Path, vf: VirtualFile): WorkspaceFileDto? {
val path = file(vf.path) ?: return null
val rel = relativeWithinBase(base, path) ?: return null
return WorkspaceFileDto(rel, vf.name, vf.isDirectory)
}
private fun gitAvailable(base: Path): Boolean {
return workspaceGitAvailable(base, gitCache)
}
private fun git(base: Path, vararg args: String): String {
return runWorkspaceGit(base, *args)
}
private fun agent(a: Agent) = AgentInfo(
name = a.name,
displayName = a.displayName,
@@ -309,3 +433,42 @@ class KiloWorkspaceRpcApiImpl : KiloWorkspaceRpcApi {
}
private fun encode(value: String) = URLEncoder.encode(value, Charsets.UTF_8)
internal fun normalizeWorkspacePath(path: String): String? {
val raw = path.trim().takeIf { it.isNotBlank() } ?: return null
return try {
val cut = raw.substringBefore('#').substringBefore('?')
val decoded = if (cut.startsWith("file:")) URI(cut).path else URLDecoder.decode(cut, StandardCharsets.UTF_8)
Path.of(decoded.replace('\\', '/')).normalize().toString()
} catch (_: Exception) {
null
}
}
internal fun workspaceGitAvailable(base: Path, cache: ConcurrentHashMap<String, Boolean> = ConcurrentHashMap()): Boolean {
if (Files.exists(base.resolve(".git"))) return true
return cache.getOrPut(base.toString()) {
runWorkspaceGit(base, "rev-parse", "--is-inside-work-tree").trim() == "true"
}
}
internal fun runWorkspaceGit(base: Path, vararg args: String): String {
return try {
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile())
val out = CapturingProcessHandler(cmd).runProcess(5_000)
out.stdout.takeIf { !out.isTimeout && out.exitCode == 0 }.orEmpty()
} catch (_: Exception) {
""
}
}
/**
* Relativizes [target] against [base], returning the forward-slash relative path, or null if
* [target] is not strictly inside [base] (path-traversal guard) or equals the base itself.
*/
internal fun relativeWithinBase(base: Path, target: Path): String? {
val path = target.normalize()
if (!path.startsWith(base)) return null
val rel = base.relativize(path).toString().replace('\\', '/')
return rel.ifBlank { null }
}
@@ -3,6 +3,8 @@ package ai.kilocode.backend.app
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -75,6 +77,22 @@ class KiloBackendChatManagerTest {
assertEquals("Enhance prompt failed: HTTP 500", error.message)
}
@Test
fun `prompt failure includes CLI response body summary`() {
val port = mock.start()
val chat = KiloBackendChatManager(scope, TestLog())
chat.start(OkHttpClient(), port, MutableSharedFlow())
mock.promptStatus = 400
mock.promptResponse = """{"issues":[{"message":"invalid source type"}]}"""
val error = assertFailsWith<RuntimeException> {
chat.prompt("ses_abc", "/test/project", PromptDto(parts = listOf(PromptPartDto(type = "text", text = "hello"))))
}
assertTrue(error.message!!.contains("prompt_async failed: HTTP 400"), error.message)
assertTrue(error.message!!.contains("chars="), error.message)
}
@Test
fun `enhance prompt cancels the HTTP request with its coroutine`() = runBlocking {
val port = mock.start()
@@ -10,6 +10,8 @@ import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.ModelStateDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.QuestionReplyDto
@@ -190,6 +192,42 @@ class KiloCliDataParserTest {
assertEquals("a.png", result.part.filename)
}
@Test
fun `parseChatEvent - part preserves synthetic flag and source metadata`() {
val data = globalEvent("""
"type": "message.part.updated",
"properties": {
"sessionID": "ses_1",
"part": {
"id": "file_1",
"sessionID": "ses_1",
"messageID": "msg_1",
"type": "file",
"mime": "text/plain",
"url": "file:///tmp/a.kt",
"filename": "a.kt",
"synthetic": true,
"source": {
"type": "file",
"path": "src/a.kt",
"text": { "value": "@src/a.kt", "start": 4, "end": 13 }
}
}
}
""")
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data)
assertNotNull(result)
assertTrue(result is ChatEventDto.PartUpdated)
assertEquals(true, result.part.synthetic)
assertEquals("file", result.part.source?.type)
assertEquals("src/a.kt", result.part.source?.path)
assertEquals("@src/a.kt", result.part.source?.text?.value)
assertEquals(4.0, result.part.source?.text?.start)
assertEquals(13.0, result.part.source?.text?.end)
}
@Test
fun `ChatEventNormalizer - user part updated sanitizes text`() {
val norm = KiloCliDataParser.ChatEventNormalizer()
@@ -1112,6 +1150,25 @@ class KiloCliDataParserTest {
)
}
@Test
fun `parseMessages - preserves synthetic and source metadata`() {
val raw = """[
{
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
"parts": [
{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "hidden", "synthetic": true },
{ "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "mime": "text/plain", "url": "file:///tmp/a.kt", "source": { "type": "file", "path": "src/a.kt", "text": { "value": "@src/a.kt", "start": 0, "end": 9 } } }
]
}
]"""
val result = KiloCliDataParser.parseMessages(raw).single()
assertEquals(true, result.parts[0].synthetic)
assertEquals("src/a.kt", result.parts[1].source?.path)
assertEquals("@src/a.kt", result.parts[1].source?.text?.value)
}
@Test
fun `parseMessages - message with tool parts`() {
val raw = """[{
@@ -1635,6 +1692,86 @@ class KiloCliDataParserTest {
assertTrue(result.contains(""""filename":"a \"b\".txt""""), result)
}
@Test
fun `buildPromptJson - file part includes source metadata`() {
val prompt = PromptDto(parts = listOf(PromptPartDto(
type = "file",
mime = "text/plain",
url = "file:///tmp/a.kt",
filename = "a.kt",
source = PartSourceDto(
type = "file",
path = "src/a.kt",
text = PartSourceTextDto("@src/a.kt", 4.0, 13.0),
),
)))
val result = KiloCliDataParser.buildPromptJson(prompt)
assertEquals(
"""{"parts":[{"type":"file","mime":"text/plain","url":"file:///tmp/a.kt","filename":"a.kt","source":{"type":"file","text":{"value":"@src/a.kt","start":4.0,"end":13.0},"path":"src/a.kt"}}]}""",
result,
)
}
@Test
fun `buildPromptJson - data file part includes source metadata`() {
val prompt = PromptDto(parts = listOf(PromptPartDto(
type = "file",
mime = "text/plain",
url = "data:text/plain;charset=utf-8,diff%20content",
filename = "git-changes.txt",
source = PartSourceDto(
type = "file",
text = PartSourceTextDto("@git-changes", 7.0, 19.0),
path = "git-changes",
),
)))
val result = KiloCliDataParser.buildPromptJson(prompt)
assertEquals(
"""{"parts":[{"type":"file","mime":"text/plain","url":"data:text/plain;charset=utf-8,diff%20content","filename":"git-changes.txt","source":{"type":"file","text":{"value":"@git-changes","start":7.0,"end":19.0},"path":"git-changes"}}]}""",
result,
)
}
@Test
fun `buildCommandJson - file part includes source metadata`() {
val prompt = PromptDto(parts = listOf(PromptPartDto(
type = "file",
mime = "text/plain",
url = "file:///tmp/a.kt",
source = PartSourceDto(
type = "file",
path = "src/a.kt",
text = PartSourceTextDto("@src/a.kt", 0.0, 9.0),
),
)))
val result = KiloCliDataParser.buildCommandJson("review", "", prompt)
assertTrue(result.contains(""""source":{"type":"file","text":{"value":"@src/a.kt","start":0.0,"end":9.0},"path":"src/a.kt"}"""), result)
}
@Test
fun `buildCommandJson - includes agent variant model and arguments`() {
val prompt = PromptDto(
parts = emptyList(),
agent = "code",
variant = "high",
providerID = "kilo",
modelID = "gpt-5",
)
val result = KiloCliDataParser.buildCommandJson("review", "src/", prompt)
assertEquals(
"""{"command":"review","arguments":"src/","agent":"code","variant":"high","model":"kilo/gpt-5"}""",
result,
)
}
// ---- buildSummarizeJson ----
@Test
@@ -0,0 +1,150 @@
package ai.kilocode.backend.rpc
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class WorkspacePathScopingTest {
// Derive an absolute, OS-portable base from the real home dir so the test runs identically on
// Windows, macOS, and Linux (no hardcoded POSIX "/home/..." literals).
private val base: Path = Path.of(System.getProperty("user.home")).resolve("kilo-scope-test").normalize()
private fun at(vararg segments: String): Path = segments.fold(base) { acc, s -> acc.resolve(s) }
@Test
fun `in-base file returns forward-slash relative path`() {
assertEquals("src/A.kt", relativeWithinBase(base, at("src", "A.kt")))
}
@Test
fun `nested file returns nested relative path`() {
assertEquals("a/b/c.kt", relativeWithinBase(base, at("a", "b", "c.kt")))
}
@Test
fun `base itself is rejected as blank`() {
assertNull(relativeWithinBase(base, base))
}
@Test
fun `sibling directory outside base is rejected`() {
assertNull(relativeWithinBase(base, base.resolveSibling("other").resolve("A.kt")))
}
@Test
fun `parent directory is rejected`() {
assertNull(relativeWithinBase(base, base.parent))
}
@Test
fun `traversal that escapes base is rejected after normalization`() {
assertNull(relativeWithinBase(base, base.resolve("..").resolve("secret").resolve("A.kt")))
}
@Test
fun `traversal that stays inside base is kept after normalization`() {
assertEquals("src/A.kt", relativeWithinBase(base, base.resolve("x").resolve("..").resolve("src").resolve("A.kt")))
}
@Test
fun `prefix sibling is not treated as inside base`() {
val sibling = base.resolveSibling(base.fileName.toString() + "-2")
assertNull(relativeWithinBase(base, sibling.resolve("A.kt")))
}
@Test
fun `normalizes encoded file URLs`() {
val path = base.resolve("dir with spaces").resolve("A.kt")
val url = path.toUri().toString() + "?query#fragment"
assertEquals(path.normalize().toString(), normalizeWorkspacePath(url))
}
@Test
fun `normalizes escaped relative paths`() {
assertEquals("src/A.kt", normalizeWorkspacePath("src%2Ftmp%2F..%2FA.kt"))
}
@Test
fun `rejects blank and invalid paths`() {
assertNull(normalizeWorkspacePath(" "))
assertNull(normalizeWorkspacePath("file://%"))
}
@Test
fun `git availability detects temp repository`() {
val dir = repo() ?: return
try {
assertTrue(workspaceGitAvailable(dir))
} finally {
delete(dir)
}
}
@Test
fun `git changes returns capped large diff without blocking`() = runBlocking {
val dir = repo() ?: return@runBlocking
try {
val file = dir.resolve("large.txt")
file.writeText("base\n")
git(dir, "add", "large.txt")
git(dir, "commit", "-m", "base")
file.writeText((1..40_000).joinToString("\n") { "line-$it" } + "\n")
val diff = assertNotNull(KiloWorkspaceRpcApiImpl().gitChanges(dir.toString()))
assertTrue(diff.startsWith("diff --git"))
assertEquals(200_000, diff.length)
} finally {
delete(dir)
}
}
@Test
fun `git changes returns null outside git repositories`() = runBlocking {
val dir = Files.createTempDirectory("kilo-non-git")
try {
assertNull(KiloWorkspaceRpcApiImpl().gitChanges(dir.toString()))
} finally {
delete(dir)
}
}
private fun repo(): Path? {
if (!gitInstalled()) return null
val dir = Files.createTempDirectory("kilo-git")
git(dir, "init")
git(dir, "config", "user.email", "test@example.com")
git(dir, "config", "user.name", "Test User")
return dir
}
private fun gitInstalled(): Boolean {
return try {
ProcessBuilder("git", "--version").start().waitFor() == 0
} catch (_: Exception) {
false
}
}
private fun git(dir: Path, vararg args: String) {
val proc = ProcessBuilder(listOf("git") + args)
.directory(dir.toFile())
.redirectErrorStream(true)
.start()
val out = proc.inputStream.bufferedReader().readText()
val code = proc.waitFor()
assertEquals(0, code, out)
}
private fun delete(dir: Path) {
Files.walk(dir).use { paths ->
paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) }
}
}
}
@@ -93,6 +93,10 @@ class MockCliServer : AutoCloseable {
@Volatile var summarizeStatus = 200
@Volatile var lastSummarizePath: String? = null
@Volatile var lastSummarizeBody: String? = null
@Volatile var promptStatus = 200
@Volatile var promptResponse = "true"
@Volatile var lastPromptPath: String? = null
@Volatile var lastPromptBody: String? = null
@Volatile var enhanced = """{"text":"Enhanced prompt"}"""
@Volatile var enhanceStatus = 200
@Volatile var lastEnhancePath: String? = null
@@ -342,11 +346,11 @@ class MockCliServer : AutoCloseable {
bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses)
bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions)
bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate)
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" ->
bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" ->
respond(output, sessionGetStatus, sessionCreate)
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" ->
bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" ->
respond(output, sessionDeleteStatus, "true")
bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> {
bare.matches(Regex("/session/ses_[^/]+")) && method == "PATCH" -> {
lastSessionRenamePath = path
lastSessionRenameBody = body
lastSessionRenameMethod = method
@@ -357,6 +361,11 @@ class MockCliServer : AutoCloseable {
lastSummarizeBody = body
respond(output, summarizeStatus, summarizeResponse)
}
bare.matches(Regex("/session/ses_[^/]+/prompt_async")) && method == "POST" -> {
lastPromptPath = path
lastPromptBody = body
respond(output, promptStatus, promptResponse)
}
bare == "/enhance-prompt" && method == "POST" -> {
lastEnhancePath = path
lastEnhanceBody = body
@@ -296,11 +296,14 @@ class KiloBackendWorkspaceTest {
@Test
fun `agents response filters hidden and subagent`() = runBlocking {
mock.providers = PROVIDERS_JSON
mock.agents = """[
{"name":"code","mode":"primary","permission":[],"options":{}},
{"name":"helper","mode":"subagent","permission":[],"options":{}},
{"name":"secret","mode":"primary","hidden":true,"permission":[],"options":{}}
]"""
mock.commands = COMMANDS_JSON
mock.skills = SKILLS_JSON
val app = setup()
val ws = ready(app)
@@ -171,6 +171,12 @@ class KiloSessionService internal constructor(
LOG.info("${ChatLogSummary.sid(id)} kind=prompt ok=true")
}
suspend fun command(id: String, dir: String, command: String, args: String, dto: PromptDto) {
LOG.info("${ChatLogSummary.sid(id)} kind=command command=$command parts=${dto.parts.size}")
call { command(id, dir, command, args, dto) }
LOG.info("${ChatLogSummary.sid(id)} kind=command ok=true")
}
/** Abort ongoing processing for a session. */
suspend fun abort(id: String, dir: String) {
call { abort(id, dir) }
@@ -4,6 +4,7 @@ package ai.kilocode.client.app
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
@@ -13,6 +14,7 @@ import com.intellij.openapi.components.Service
import ai.kilocode.log.KiloLog
import fleet.rpc.client.durable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
@@ -126,6 +128,26 @@ class KiloWorkspaceService internal constructor(
}
}
suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto {
return try {
call { searchFiles(directory, query, limit) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LOG.warn("workspace file search failed for directory=$directory query=$query", e)
FileSearchResultDto()
}
}
suspend fun gitChanges(directory: String): String? {
return try {
call { gitChanges(directory) }
} catch (e: Exception) {
LOG.warn("git changes lookup failed for directory=$directory", e)
null
}
}
suspend fun openPath(directory: String, path: String): Boolean {
val match = files(directory, path).firstOrNull() ?: return false
return try {
@@ -35,6 +35,11 @@ class SessionSidePanelManager(
private val status: () -> Map<String, SessionActivityKind> = { project.service<KiloSessionService>().activity() },
private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
private val timers: UiTimerSource = UiTimers,
private val request: (JComponent) -> Unit = { focus ->
ApplicationManager.getApplication().invokeLater({
IdeFocusManager.getInstance(project).requestFocusInProject(focus, project)
}, ModalityState.defaultModalityState())
},
) : SessionManager, Disposable {
val component: JPanel = object : JPanel(BorderLayout()), DataProvider {
override fun getData(dataId: String): Any? {
@@ -120,7 +125,7 @@ class SessionSidePanelManager(
val view = cached ?: createHistory().also { panel = it }
if (cached != null && view is HistoryPanel) view.refresh()
if (current == null && component.componentCount == 1 && component.getComponent(0) === view) {
focusHistory(view)
focus((view as? HistoryPanel)?.defaultFocusedComponent)
return
}
current = null
@@ -128,14 +133,12 @@ class SessionSidePanelManager(
component.add(view, BorderLayout.CENTER)
component.revalidate()
component.repaint()
focusHistory(view)
focus((view as? HistoryPanel)?.defaultFocusedComponent)
}
private fun focusHistory(view: JComponent) {
val focus = (view as? HistoryPanel)?.defaultFocusedComponent ?: return
ApplicationManager.getApplication().invokeLater({
IdeFocusManager.getInstance(project).requestFocusInProject(focus, project)
}, ModalityState.defaultModalityState())
private fun focus(component: JComponent?) {
val focus = component ?: return
request(focus)
}
private fun createHistory(): JComponent {
@@ -181,6 +184,7 @@ class SessionSidePanelManager(
component.add(ui, BorderLayout.CENTER)
component.revalidate()
component.repaint()
focus(ui.defaultFocusedComponent)
}
private fun register(ui: SessionUi?) {
@@ -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
@@ -18,7 +19,11 @@ import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.ReasoningPicker
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.MentionAction
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.prompt.SlashAction
import ai.kilocode.client.session.ui.prompt.mentionParts as promptMentionParts
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionDropOverlay
import ai.kilocode.client.session.ui.SessionRootPanel
@@ -40,6 +45,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
import ai.kilocode.client.session.views.permission.PermissionView
import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.KiloSettingsConfigurable
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.layout.Stack
@@ -67,6 +73,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
@@ -168,6 +175,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
@@ -336,12 +344,21 @@ class SessionUi(
scroll.onScroll = overlay::clear
connection = ConnectionPanel(this, controller)
completion = KiloPromptCompletionProvider(
workspace = workspace,
service = workspaces,
actions = slashActions(),
mentions = mentionActions(),
scope = cs,
)
prompt = PromptPanel(
project = project,
selection = selection,
onSend = { text, files -> sendPrompt(text, files) },
onAbort = { controller.abort() },
onEnhance = controller::enhancePrompt,
onMentions = ::mentionParts,
completion = completion,
)
drop = SessionDropOverlay()
@@ -417,6 +434,7 @@ class SessionUi(
prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant)
prompt.setResetVisible(m.modelOverride)
prompt.setReady(m.isReady())
prompt.refreshHighlights()
}
is SessionControllerEvent.ViewChanged.ShowProgress -> {
@@ -590,10 +608,63 @@ class SessionUi(
}
prompt.clear()
val follow = scroll.atBottom()
val action = completion.clientAction(text)
if (action != null) {
action.action()
scroll.followBottom(follow)
return
}
val command = completion.serverCommand(text)
if (command != null) {
controller.command(command.first, command.second, files)
scroll.followBottom(follow)
return
}
controller.prompt(text, files)
scroll.followBottom(follow)
}
private fun slashActions(): List<SlashAction> {
val fns: Map<SlashAction.Spec, () -> Unit> = mapOf(
SlashAction.NEW to { manager?.newSession() },
SlashAction.SESSIONS to { manager?.showHistory() },
SlashAction.MODELS to { prompt.model.open() },
SlashAction.AGENTS to { prompt.mode.open() },
SlashAction.VARIANT to { prompt.reasoning.open() },
SlashAction.COMPACT to { controller.compact() },
SlashAction.SETTINGS to { openKiloSettings() },
SlashAction.HELP to { BrowserUtil.browse("https://kilo.ai/docs") },
)
return SlashAction.ALL.map { spec -> bind(spec, fns.getValue(spec)) }
}
private fun bind(spec: SlashAction.Spec, action: () -> Unit) = SlashAction(
spec.name,
KiloBundle.message(spec.descriptionKey),
spec.hints,
action,
)
private fun mentionActions(): List<MentionAction> = MentionAction.ALL.map(::bind)
private fun bind(spec: MentionAction.Spec) = MentionAction(
spec.name,
KiloBundle.message(spec.descriptionKey),
spec.hints,
spec.available,
)
private fun mentionParts(text: String): List<PromptPartDto> = runBlockingCancellable {
val names = MentionAction.ALL.mapTo(mutableSetOf()) { it.name }
promptMentionParts(
text = text,
directory = workspace.directory,
reserved = names,
resolve = { path -> workspaces.files(workspace.directory, path).isNotEmpty() },
gitChanges = { workspaces.gitChanges(workspace.directory) },
)
}
private fun openFile(path: String) {
cs.launch {
workspaces.openPath(workspace.directory, path)
@@ -708,6 +779,16 @@ class SessionUi(
)
}
private fun openKiloSettings() {
ShowSettingsUtil.getInstance().showSettingsDialog(
project,
Predicate { cfg: Configurable ->
cfg is ConfigurableWithId && cfg.getId() == KiloSettingsConfigurable.ID
},
{ _: Configurable -> },
)
}
override fun dispose() {
disposed = true
hide.stop()
@@ -54,6 +54,7 @@ import ai.kilocode.log.KiloLog
import com.intellij.openapi.util.Disposer
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
@@ -97,6 +98,14 @@ class SessionController(
private data class OrganizationTarget(val org: String?)
private data class Followup(val dir: String, val time: Long)
private data class Pref(val agent: String?, val model: String?, val variants: List<String>, val variant: String?, val reset: Boolean)
private data class Dispatch(
val kind: String,
val source: String,
val text: String,
val props: Map<String, String>,
val start: String,
val exists: Boolean,
)
companion object {
private val LOG = KiloLog.create(SessionController::class.java)
@@ -134,6 +143,7 @@ class SessionController(
private var tool: String? = null
private var eventJob: Job? = null
private var drainJob: Job? = null
private var creating: CompletableDeferred<String?>? = null
private val childJobs: MutableMap<String, Job> = mutableMapOf()
private val childIds: MutableSet<String> = mutableSetOf()
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
@@ -239,40 +249,41 @@ class SessionController(
val dto = promptDto(text, files)
val props = promptProps(files)
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
dispatch(Dispatch("prompt", "user", text, props, start, exists)) { id ->
sessions.prompt(id, directory, dto)
}
}
fun command(command: String, args: String, files: List<PromptPartDto> = emptyList()) {
assertEdt()
val start = sid ?: ref?.key ?: "pending"
val exists = sid != null
val dto = promptDto("", files)
val props = promptProps(files)
LOG.debug { "${ChatLogSummary.sid(start)} kind=command command=$command args=${args.length} ${ChatLogSummary.dir(directory)}" }
dispatch(Dispatch("command", "command", args, props, start, exists)) { id ->
sessions.command(id, directory, command, args, dto)
}
}
private fun dispatch(data: Dispatch, send: suspend (String) -> Unit) {
assertEdt()
capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf(
"source" to "user",
"hasExistingSession" to exists.toString(),
"textLength" to bucket(text),
) + props)
"source" to data.source,
"hasExistingSession" to data.exists.toString(),
"textLength" to bucket(data.text),
) + data.props)
showSession()
val pending = sid?.let { CompletableDeferred(it) } ?: session()
cs.launch {
try {
val id = sid ?: run {
val session = sessions.create(directory)
runEdt {
if (disposed) return@runEdt
ref = SessionRef.Local(session)
setRecentSessionsState(RecentsState.Idle)
updateModel {
model.setSession(session)
}
}
if (disposed) return@launch
val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session"
LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true")
capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains"))
runEdt {
if (disposed) return@runEdt
subscribeEvents()
}
session.id
}
sessions.prompt(id, directory, dto)
capture("Conversation Message", sessionProps(id) + mapOf("source" to "user", "hasExistingSession" to exists.toString()) + props)
LOG.debug { "${ChatLogSummary.sid(id)} kind=prompt dispatched=true" }
val id = pending.await() ?: return@launch
send(id)
capture("Conversation Message", sessionProps(id) + mapOf("source" to data.source, "hasExistingSession" to data.exists.toString()) + data.props)
LOG.debug { "${ChatLogSummary.sid(id)} kind=${data.kind} dispatched=true" }
} catch (e: Exception) {
capture("Session Error", sessionProps(sid ?: ref?.key ?: start) + mapOf("context" to "prompt", "errorClass" to e::class.java.name))
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: start)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
capture("Session Error", sessionProps(sid ?: ref?.key ?: data.start) + mapOf("context" to data.kind, "errorClass" to e::class.java.name))
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: data.start)} kind=${data.kind} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (disposed) return@edt
val msg = e.message ?: KiloBundle.message("session.error.prompt")
@@ -284,6 +295,47 @@ class SessionController(
}
}
private fun session(): CompletableDeferred<String?> {
assertEdt()
val pending = creating
if (pending != null) return pending
val next = CompletableDeferred<String?>()
creating = next
cs.launch {
try {
next.complete(createSession())
} catch (e: Exception) {
next.completeExceptionally(e)
} finally {
edt {
if (creating === next) creating = null
}
}
}
return next
}
private suspend fun createSession(): String? {
val session = sessions.create(directory)
runEdt {
if (disposed) return@runEdt
ref = SessionRef.Local(session)
setRecentSessionsState(RecentsState.Idle)
updateModel {
model.setSession(session)
}
}
if (disposed) return null
val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session"
LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true")
capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains"))
runEdt {
if (disposed) return@runEdt
subscribeEvents()
}
return session.id
}
fun abort() {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=abort" }
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.TodoViewDto
@@ -71,6 +72,7 @@ class FileAttachment(id: String) : Content(id) {
var mime: String = "application/octet-stream"
var url: String = ""
var filename: String? = null
var source: PartSourceDto? = null
}
/** Tool invocation with lifecycle state. */
@@ -41,6 +41,7 @@ class SessionModel {
private val entries = LinkedHashMap<String, Message>()
private val turnEntries = LinkedHashMap<String, Turn>()
private val hiddenText = mutableSetOf<Pair<String, String>>()
var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
var version: String? = null
@@ -140,6 +141,7 @@ class SessionModel {
@RequiresEdt
fun removeMessage(id: String) {
if (entries.remove(id) == null) return
hiddenText.removeAll { it.first == id }
fire(SessionModelEvent.MessageRemoved(id))
regroup()
updateHeader()
@@ -147,6 +149,7 @@ class SessionModel {
@RequiresEdt
fun removeContent(messageId: String, contentId: String) {
hiddenText.remove(messageId to contentId)
val msg = entries[messageId] ?: return
if (msg.parts.remove(contentId) == null) return
fire(SessionModelEvent.ContentRemoved(messageId, contentId))
@@ -157,6 +160,16 @@ class SessionModel {
fun updateContent(messageId: String, dto: PartDto) {
if (dto.type in SILENT_PART_TYPES) return
val msg = entries[messageId] ?: return
val key = messageId to dto.id
if (hiddenSynthetic(msg, dto)) {
hiddenText.add(key)
if (msg.parts.remove(dto.id) != null) {
fire(SessionModelEvent.ContentRemoved(messageId, dto.id))
updateHeader()
}
return
}
hiddenText.remove(key)
val existing = msg.parts[dto.id]
if (empty(dto)) {
if (existing is Text) removeContent(messageId, dto.id)
@@ -175,6 +188,7 @@ class SessionModel {
@RequiresEdt
fun appendDelta(messageId: String, contentId: String, delta: String) {
val msg = entries[messageId] ?: return
if (hiddenText.contains(messageId to contentId)) return
val existing = msg.parts[contentId]
val created = existing == null
if (existing != null) {
@@ -238,6 +252,7 @@ class SessionModel {
@RequiresEdt
fun loadHistory(history: List<MessageWithPartsDto>) {
entries.clear()
hiddenText.clear()
session = null
state = SessionState.Idle
diff = emptyList()
@@ -247,6 +262,10 @@ class SessionModel {
val item = Message(msg.info)
for (part in msg.parts) {
if (part.type in SILENT_PART_TYPES) continue
if (hiddenSynthetic(item, part)) {
hiddenText.add(msg.info.id to part.id)
continue
}
if (empty(part)) continue
val content = fromDto(part, part.text)
item.parts[content.id] = content
@@ -262,6 +281,7 @@ class SessionModel {
fun clear() {
entries.clear()
turnEntries.clear()
hiddenText.clear()
session = null
state = SessionState.Idle
diff = emptyList()
@@ -382,6 +402,7 @@ class SessionModel {
existing.mime = dto.mime ?: "application/octet-stream"
existing.url = dto.url ?: ""
existing.filename = dto.filename
existing.source = dto.source
}
is Tool -> {
existing.kind = toolKind(dto.tool)
@@ -410,6 +431,9 @@ class SessionModel {
private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true
private fun hiddenSynthetic(msg: Message, dto: PartDto) =
msg.info.role == "user" && dto.type == "text" && dto.synthetic == true
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
val content = text ?: dto.text
return when (dto.type) {
@@ -424,6 +448,7 @@ class SessionModel {
mime = dto.mime ?: "application/octet-stream"
url = dto.url ?: ""
filename = dto.filename
source = dto.source
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
state = parseToolState(dto.state)
@@ -80,6 +80,11 @@ class ReasoningPicker : PickerButton() {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
fun open() {
if (!isEnabled || items.isEmpty()) return
showPopup()
}
private fun showPopup() {
val step = object : BaseListPopupStep<Item>("", items) {
override fun getTextFor(value: Item) = value.display
@@ -1,12 +1,27 @@
package ai.kilocode.client.session.ui.editor
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.SendPromptContext
import ai.kilocode.client.session.ui.selection.SessionSelection
import com.intellij.ide.actions.UndoRedoAction
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
import com.intellij.openapi.command.undo.UndoManager
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.PlainTextLanguage
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.ui.EditorTextField
import com.intellij.ui.LanguageTextField
import com.intellij.util.textCompletion.TextCompletionProvider
import com.intellij.util.textCompletion.TextCompletionUtil
/**
* A session-scoped [EditorTextField] for plain-text input.
@@ -23,13 +38,74 @@ import com.intellij.ui.EditorTextField
* wrapping here.
*/
internal open class SessionEditorTextField(
project: Project,
private val project: Project,
private val ctx: SendPromptContext? = null,
completion: TextCompletionProvider? = null,
private val selection: SessionSelection? = null,
) : EditorTextField(project, PlainTextFileType.INSTANCE) {
) : EditorTextField(
completion?.let {
LanguageTextField.createDocument(
"",
PlainTextLanguage.INSTANCE,
project,
TextCompletionUtil.DocumentWithCompletionCreator(it, true),
)
},
project,
PlainTextFileType.INSTANCE,
) {
private val undo = action(KiloBundle.message("session.editor.undo"), true)
private val redo = action(KiloBundle.message("session.editor.redo"), false)
init {
addSettingsProvider(::install)
}
override fun uiDataSnapshot(sink: DataSink) {
super.uiDataSnapshot(sink)
selection?.provideCopy(sink) { text }
ctx?.let { sink.set(PromptDataKeys.SEND, it) }
file()?.let { sink.set(PlatformCoreDataKeys.FILE_EDITOR, it) }
}
private fun install(editor: Editor) {
editor.contentComponent.putClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER, true)
// Workaround: global $Undo/$Redo can miss the synthetic FileEditor for this embedded
// EditorTextField. Bind the shortcuts locally until the platform data context targets it reliably.
val manager = ActionManager.getInstance()
manager.getAction(IdeActions.ACTION_UNDO)?.shortcutSet?.let {
undo.registerCustomShortcutSet(it, editor.contentComponent)
}
manager.getAction(IdeActions.ACTION_REDO)?.shortcutSet?.let {
redo.registerCustomShortcutSet(it, editor.contentComponent)
}
}
private fun action(text: String, undo: Boolean) = object : DumbAwareAction(text) {
override fun update(e: AnActionEvent) {
e.presentation.isEnabled = available(undo)
}
override fun actionPerformed(e: AnActionEvent) {
if (project.isDisposed) return
val file = file() ?: return
val manager = UndoManager.getInstance(project)
if (undo) {
if (manager.isUndoAvailable(file)) manager.undo(file)
return
}
if (manager.isRedoAvailable(file)) manager.redo(file)
}
}
private fun available(undo: Boolean): Boolean {
if (project.isDisposed) return false
val file = file() ?: return false
val manager = UndoManager.getInstance(project)
return if (undo) manager.isUndoAvailable(file) else manager.isRedoAvailable(file)
}
private fun file(): TextEditor? {
return getEditor(false)?.let(TextEditorProvider.getInstance()::getTextEditor)
}
}
@@ -66,6 +66,11 @@ class ModePicker : PickerButton() {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
fun open() {
if (!isEnabled || items.isEmpty()) return
showPopup()
}
private fun showPopup() {
val item = selected ?: items.first()
val popup = JBPopupFactory.getInstance()
@@ -137,6 +137,11 @@ class ModelPicker : PickerButton() {
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
fun open() {
if (!isEnabled || (items.isEmpty() && !allowEmpty)) return
showPopup()
}
private fun showPopup() {
val rows = modelPickerRows(items, favorites(), "", allowEmpty, emptyText, includeSmall)
val model = CollectionListModel(rows)
@@ -0,0 +1,306 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.InsertionContext
import com.intellij.codeInsight.completion.PlainPrefixMatcher
import com.intellij.codeInsight.completion.PrioritizedLookupElement
import com.intellij.codeInsight.lookup.AutoCompletionPolicy
import com.intellij.codeInsight.lookup.CharFilter
import com.intellij.codeInsight.lookup.LookupElement
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.util.textCompletion.TextCompletionProvider
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class KiloPromptCompletionProvider(
private val workspace: Workspace,
private val service: KiloWorkspaceService,
private val actions: List<SlashAction>,
private val mentions: List<MentionAction>,
private val scope: CoroutineScope,
) : TextCompletionProvider, DumbAware {
private val paths = Collections.synchronizedSet(mutableSetOf<String>())
private val exists = Collections.synchronizedMap(mutableMapOf<String, Boolean>())
private val pending = Collections.synchronizedSet(mutableSetOf<String>())
private val cache: MutableMap<String, FileSearchResultDto> = Collections.synchronizedMap(
object : LinkedHashMap<String, FileSearchResultDto>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, FileSearchResultDto>) = size > 64
},
)
data class Highlight(val start: Int, val end: Int, val kind: HighlightKind)
enum class HighlightKind { MENTION, COMMAND, INVALID }
/** A file mention token at a caret/mouse offset, with whether it resolves to a real file. */
data class MentionHit(val start: Int, val end: Int, val value: String, val resolved: Boolean)
/**
* The file mention spanning [offset], or null when the offset is outside a mention,
* on a special (`@git-changes`) token, or on a mention not yet validated.
*/
fun mentionAt(text: String, offset: Int): MentionHit? {
val span = mentionSpans(text).firstOrNull { offset in it.start..it.end } ?: return null
if (span.value in mentionNames()) return null
val resolved = span.value in paths || exists[span.value] == true
if (!resolved && exists[span.value] != false) return null
return MentionHit(span.start, span.end, span.value, resolved)
}
/** Open a resolved file mention using the workspace's go-to-file plumbing. */
fun navigate(value: String) {
scope.launch { service.openPath(workspace.directory, value) }
}
fun clearMentions() {
paths.clear()
exists.clear()
pending.clear()
cache.clear()
}
fun prewarm() {
if (cache.containsKey("")) return
scope.launch {
val result = service.searchFiles(workspace.directory, "", 50)
if (result.files.isNotEmpty() || result.git) cache.putIfAbsent("", result)
}
}
fun inside(text: String, caret: Int): Boolean = mentionSpans(text).any { span -> caret in span.start..span.end }
private fun clientTokens(): Set<String> = actions.flatMapTo(mutableSetOf()) { action ->
listOf(action.name) + action.hints
}
fun clientAction(text: String): SlashAction? {
val name = commandName(text) ?: return null
return actions.firstOrNull { action -> name == action.name || name in action.hints }
}
fun mentionNames(): Set<String> = mentions.mapTo(mutableSetOf()) { it.name }
fun serverCommand(text: String): Pair<String, String>? {
val name = commandName(text) ?: return null
if (actions.any { action -> name == action.name || name in action.hints }) return null
if (workspace.state.value.commands.none { it.name == name }) return null
val raw = text.trimStart()
return name to raw.drop(name.length + 1).trimStart()
}
fun highlights(text: String, caret: Int = -1): List<Highlight> = buildList {
val command = text.takeIf { it.startsWith('/') }
?.drop(1)
?.takeWhile { !it.isWhitespace() }
?.takeIf { it.isNotBlank() }
val commands = workspace.state.value.commands.mapTo(mutableSetOf()) { it.name }
if (command != null && (clientAction("/$command") != null || command in commands)) {
add(Highlight(0, command.length + 1, HighlightKind.COMMAND))
}
mentionSpans(text).forEach { span ->
val under = caret in span.start..span.end
when {
span.value in mentionNames() -> add(Highlight(span.start, span.end, HighlightKind.MENTION))
span.value in paths || exists[span.value] == true -> add(Highlight(span.start, span.end, HighlightKind.MENTION))
under -> Unit
exists[span.value] == false -> add(Highlight(span.start, span.end, HighlightKind.INVALID))
}
}
}
fun validate(text: String, caret: Int, onResolved: () -> Unit) {
mentionSpans(text).forEach { span ->
val value = span.value
if (value in mentionNames()) return@forEach
if (value in paths) return@forEach
if (caret in span.start..span.end) return@forEach
if (exists.containsKey(value)) return@forEach
if (!pending.add(value)) return@forEach
scope.launch {
val ok = runCatching { service.files(workspace.directory, value).isNotEmpty() }.getOrDefault(false)
exists[value] = ok
pending.remove(value)
onResolved()
}
}
}
override fun getAdvertisement(): String? = null
override fun getPrefix(text: String, offset: Int): String? = token(text, offset)?.prefix
override fun applyPrefixMatcher(result: CompletionResultSet, prefix: String): CompletionResultSet =
result.withPrefixMatcher(PlainPrefixMatcher(prefix)).caseInsensitive()
override fun acceptChar(c: Char): CharFilter.Result = when {
c.isWhitespace() -> CharFilter.Result.HIDE_LOOKUP
else -> CharFilter.Result.ADD_TO_PREFIX
}
override fun fillCompletionVariants(parameters: CompletionParameters, prefix: String, result: CompletionResultSet) {
when (token(parameters.originalFile.text, parameters.offset)?.kind) {
Kind.SLASH -> slash(prefix, result)
Kind.MENTION -> mention(prefix, result)
null -> Unit
}
result.stopHere()
}
private fun slash(prefix: String, result: CompletionResultSet) {
result.restartCompletionOnAnyPrefixChange()
val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE)
val names = clientTokens()
val clients = actions.filter { action -> matches(prefix, action.name, action.hints) }
clients.forEach { action -> out.addElement(client(action)) }
val commands = workspace.state.value.commands
.filter { it.name !in names && matches(prefix, it.name, it.hints) }
commands.forEach { command -> out.addElement(server(command)) }
if (clients.isNotEmpty() || commands.isNotEmpty()) return
result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE)
.addElement(info(prefix, KiloBundle.message("prompt.completion.noMatches")))
}
private fun mention(prefix: String, result: CompletionResultSet) {
result.restartCompletionOnAnyPrefixChange()
val out = result.withPrefixMatcher(PlainPrefixMatcher.ALWAYS_TRUE)
val search = search(prefix)
val known = mentions.filter { action -> matches(prefix, action.name, action.hints) && action.available(search) }
known.forEach { action -> out.addElement(prioritize(resource(action))) }
if (search.indexing) {
val msg = KiloBundle.message("prompt.mention.indexing")
result.addLookupAdvertisement(msg)
out.addElement(info(prefix, msg))
return
}
search.files.forEach { file -> out.addElement(file(file)) }
if (known.isEmpty() && search.files.isEmpty()) {
val msg = KiloBundle.message("prompt.completion.noMatches")
out.addElement(info(prefix, msg))
}
}
private fun search(prefix: String): FileSearchResultDto = cache[prefix] ?: fetch(prefix)
private fun fetch(prefix: String): FileSearchResultDto {
val result = runBlockingCancellable { service.searchFiles(workspace.directory, prefix, 50) }
cache[prefix] = result
return result
}
private fun info(prefix: String, msg: String): LookupElement = LookupElementBuilder.create(msg)
.withPresentableText(msg)
.withIcon(AllIcons.General.Information)
.withInsertHandler { ctx, _ ->
val start = (ctx.startOffset - prefix.length).coerceAtLeast(0)
val tail = ctx.tailOffset.coerceAtMost(ctx.document.textLength)
val end = (tail until ctx.document.textLength).firstOrNull { ctx.document.text[it].isWhitespace() }
?: ctx.document.textLength
ctx.document.replaceString(start, end, prefix)
ctx.editor.caretModel.moveToOffset(start + prefix.length)
}
.withAutoCompletionPolicy(AutoCompletionPolicy.NEVER_AUTOCOMPLETE)
private fun matches(prefix: String, name: String, hints: List<String>): Boolean =
(listOf(name) + hints).any { it.startsWith(prefix, ignoreCase = true) }
private fun commandName(text: String): String? {
val raw = text.trimStart()
if (!raw.startsWith('/')) return null
return raw.drop(1).takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
}
private fun client(action: SlashAction): LookupElement = LookupElementBuilder.create(action.name)
.withPresentableText("/${action.name}")
.withTailText(" ${action.description}", true)
.withLookupStrings(action.hints)
.withIcon(AllIcons.Actions.Execute)
.withInsertHandler { ctx, _ ->
ctx.document.setText("")
ApplicationManager.getApplication().invokeLater { action.action() }
}
private fun server(command: CommandDto): LookupElement = LookupElementBuilder.create(command.name)
.withPresentableText("/${command.name}")
.withTailText(command.description?.let { " $it" } ?: "", true)
.withTypeText(command.source)
.withLookupStrings(command.hints)
.withIcon(AllIcons.Nodes.Function)
.withInsertHandler { ctx, _ -> replace(ctx, "/${command.name} ", false) }
private fun resource(action: MentionAction): LookupElement = LookupElementBuilder.create(action.name)
.withPresentableText("@${action.name}")
.withTailText(" ${action.description}", true)
.withLookupStrings(action.hints)
.withIcon(AllIcons.Nodes.Tag)
.withInsertHandler { ctx, _ -> replace(ctx, "@${action.name} ", false) }
private fun prioritize(element: LookupElement): LookupElement =
PrioritizedLookupElement.withGrouping(PrioritizedLookupElement.withPriority(element, 100.0), 100)
private fun file(file: WorkspaceFileDto): LookupElement = LookupElementBuilder.create(file.path)
.withPresentableText("@${file.path}")
.withTailText(parent(file.path), true)
.withIcon(icon(file))
.withLookupString(file.name)
.withInsertHandler { ctx, _ -> replace(ctx, "@${file.path} ", true, file.path) }
private fun icon(file: WorkspaceFileDto) = when {
file.directory -> AllIcons.Nodes.Folder
else -> FileTypeManager.getInstance().getFileTypeByFileName(file.name).icon ?: AllIcons.FileTypes.Text
}
private fun replace(ctx: InsertionContext, value: String, trim: Boolean, path: String? = null) {
val text = ctx.document.text
val offset = ctx.startOffset.coerceAtMost(text.length)
val start = (offset - 1 downTo 0).firstOrNull { text[it].isWhitespace() }?.plus(1) ?: 0
val end = tokenEnd(text, start)
val next = text.getOrNull(end)
val insert = if (trim && next?.isWhitespace() == true) value.trimEnd() else value
path?.let {
paths.add(it)
exists[it] = true
}
ctx.document.replaceString(start, end, insert)
ctx.editor.caretModel.moveToOffset(start + insert.length)
}
private fun tokenEnd(text: String, start: Int): Int =
(start until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length
private fun parent(path: String): String {
val idx = path.lastIndexOf('/')
if (idx <= 0) return ""
return " ${path.substring(0, idx)}"
}
private fun token(text: String, offset: Int): Token? {
val pos = offset.coerceIn(0, text.length)
val start = (pos - 1 downTo 0).firstOrNull { text[it].isWhitespace() }?.plus(1) ?: 0
val end = (pos until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length
val head = text.substring(start, pos)
val raw = text.substring(start, end)
if (raw.startsWith("/") && text.take(start).isBlank() && raw.indexOf(' ') < 0) return Token(Kind.SLASH, head.drop(1))
if (raw.startsWith("@") && raw.indexOf(' ') < 0) return Token(Kind.MENTION, head.drop(1))
return null
}
private data class Token(val kind: Kind, val prefix: String)
private fun mentionSpans(text: String): List<Mention> = promptMentions(text)
private enum class Kind { SLASH, MENTION }
}
@@ -0,0 +1,33 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.rpc.dto.FileSearchResultDto
data class MentionAction(
val name: String,
val description: String,
val hints: List<String> = emptyList(),
val available: (FileSearchResultDto) -> Boolean,
) {
data class Spec(
val name: String,
val descriptionKey: String,
val hints: List<String> = emptyList(),
val filename: String,
val uri: String,
val available: (FileSearchResultDto) -> Boolean,
) {
val token: String get() = "@$name"
}
companion object {
val GIT_CHANGES = Spec(
"git-changes",
"prompt.mention.gitChanges",
filename = "git-changes.txt",
uri = "git-changes",
available = { it.git },
)
val ALL = listOf(GIT_CHANGES)
}
}
@@ -0,0 +1,103 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.event.EditorMouseEvent
import com.intellij.openapi.editor.event.EditorMouseListener
import com.intellij.openapi.editor.event.EditorMouseMotionListener
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Cursor
import java.awt.event.MouseEvent
/**
* Makes resolved `@file` mentions behave like the platform's "Go to File":
*
* - Holding the navigation modifier (Cmd on macOS, Ctrl elsewhere) and hovering a resolved
* mention paints it as a hyperlink and shows the hand cursor; clicking opens the file.
* - Go to Declaration (Cmd-B / Ctrl-B) opens the mention under the caret.
* - Hovering an unresolved (red) mention shows a tooltip explaining why it cannot be resolved.
*
* Implemented locally on the frontend editor — like completion and undo here — because the
* platform navigation machinery targets the backend-shared editor in split mode.
*/
internal class MentionNavigator(
private val editor: EditorEx,
private val provider: KiloPromptCompletionProvider,
) : EditorMouseListener, EditorMouseMotionListener {
private var link: RangeHighlighter? = null
@RequiresEdt
fun install() {
editor.addEditorMouseListener(this)
editor.addEditorMouseMotionListener(this)
val base = ActionManager.getInstance().getAction(IdeActions.ACTION_GOTO_DECLARATION) ?: return
object : DumbAwareAction(KiloBundle.message("prompt.mention.goto")) {
override fun actionPerformed(e: AnActionEvent) {
val hit = provider.mentionAt(editor.document.text, editor.caretModel.offset) ?: return
if (hit.resolved) provider.navigate(hit.value)
}
}.registerCustomShortcutSet(base.shortcutSet, editor.contentComponent)
}
override fun mouseMoved(e: EditorMouseEvent) {
val hit = if (e.isOverText) provider.mentionAt(editor.document.text, e.offset) else null
syncTooltip(hit)
syncLink(hit, e.mouseEvent)
}
override fun mouseClicked(e: EditorMouseEvent) {
if (!modifier(e.mouseEvent) || !e.isOverText) return
val hit = provider.mentionAt(editor.document.text, e.offset) ?: return
if (!hit.resolved) return
e.consume()
provider.navigate(hit.value)
}
override fun mouseExited(e: EditorMouseEvent) {
syncTooltip(null)
clearLink()
}
private fun syncTooltip(hit: KiloPromptCompletionProvider.MentionHit?) {
val text = hit?.takeIf { !it.resolved }?.let { KiloBundle.message("prompt.mention.unresolved", it.value) }
if (editor.contentComponent.toolTipText != text) editor.contentComponent.toolTipText = text
}
private fun syncLink(hit: KiloPromptCompletionProvider.MentionHit?, mouse: MouseEvent) {
val active = hit?.takeIf { it.resolved && modifier(mouse) }
if (active == null) {
clearLink()
return
}
if (link?.startOffset == active.start && link?.endOffset == active.end) return
clearLink()
val attributes = EditorColorsManager.getInstance().globalScheme.getAttributes(EditorColors.REFERENCE_HYPERLINK_COLOR)
link = editor.markupModel.addRangeHighlighter(
active.start,
active.end,
HighlighterLayer.HYPERLINK,
attributes,
HighlighterTargetArea.EXACT_RANGE,
)
editor.setCustomCursor(MentionNavigator::class.java, Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
}
private fun clearLink() {
link?.let(editor.markupModel::removeHighlighter)
link = null
editor.setCustomCursor(MentionNavigator::class.java, null)
}
private fun modifier(e: MouseEvent) = if (SystemInfo.isMac) e.isMetaDown else e.isControlDown
}
@@ -3,9 +3,11 @@ package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.session.ui.editor.SessionEditorTextField
import ai.kilocode.client.session.ui.selection.SessionSelection
import com.intellij.openapi.project.Project
import com.intellij.util.textCompletion.TextCompletionProvider
internal class PromptEditorTextField(
project: Project,
ctx: SendPromptContext,
completion: TextCompletionProvider?,
selection: SessionSelection? = null,
) : SessionEditorTextField(project, ctx, selection)
) : SessionEditorTextField(project, ctx, completion, selection)
@@ -0,0 +1,89 @@
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 kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
data class Mention(val value: String, val start: Int, val end: Int)
fun promptMentions(text: String): List<Mention> = buildList {
var pos = 0
while (pos < text.length) {
val start = (pos until text.length).firstOrNull { !text[it].isWhitespace() } ?: return@buildList
val end = tokenEnd(text, start)
if (text[start] == '@' && end > start + 1) add(Mention(text.substring(start + 1, end), start, end))
pos = end + 1
}
}
fun fileMentions(text: String, reserved: Set<String>): List<Mention> {
val seen = mutableSetOf<String>()
return promptMentions(text).filter { item -> item.value !in reserved && seen.add(item.value) }
}
suspend fun mentionParts(
text: String,
directory: String,
reserved: Set<String>,
resolve: suspend (String) -> Boolean,
gitChanges: suspend () -> String?,
): List<PromptPartDto> = coroutineScope {
val files = fileMentions(text, reserved)
val paths = files.map { item -> async { item.value to resolve(item.value) } }
.mapNotNullTo(mutableSetOf()) { item -> item.await().takeIf { it.second }?.first }
buildList {
addAll(mentionFileParts(text, paths, directory))
if (text.contains(MentionAction.GIT_CHANGES.token)) gitChangesPart(text, gitChanges())?.let(::add)
}
}
fun mentionFileParts(text: String, paths: Set<String>, directory: String): List<PromptPartDto> = buildList {
fileMentions(text, emptySet()).filter { item -> item.value in paths }.forEach { mention ->
val target = runCatching {
val item = Path.of(mention.value)
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", "@${mention.value}", mention.start, path = mention.value),
))
}
}
fun gitChangesPart(text: String, diff: String?): PromptPartDto? {
val spec = MentionAction.GIT_CHANGES
val start = text.mentionStart(spec.token) ?: return null
val value = diff?.takeIf { it.isNotBlank() } ?: return null
return dataPart(spec.filename, value, source("file", spec.token, start, path = spec.uri))
}
private fun String.mentionStart(token: String): Int? = promptMentions(this)
.firstOrNull { item -> "@${item.value}" == token }
?.start
private fun tokenEnd(text: String, start: Int): Int =
(start until text.length).firstOrNull { text[it].isWhitespace() } ?: text.length
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,
) = PartSourceDto(
type = type,
text = PartSourceTextDto(value = token, start = start.toDouble(), end = (start + token.length).toDouble()),
path = path,
)
@@ -21,6 +21,12 @@ import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.PromptPartDto
import com.intellij.icons.AllIcons
import com.intellij.codeInsight.completion.CodeCompletionHandlerBase
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupEx
import com.intellij.codeInsight.lookup.LookupManagerListener
import com.intellij.codeInsight.lookup.LookupPositionStrategy
import com.intellij.codeInsight.lookup.LookupPresentation
import com.intellij.ide.DataManager
import com.intellij.ide.dnd.DnDEvent
import com.intellij.ide.dnd.DnDSupport
@@ -28,18 +34,28 @@ import com.intellij.ide.dnd.FileCopyPasteUtil
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
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.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.editor.event.CaretEvent
import com.intellij.openapi.editor.event.CaretListener
import com.intellij.openapi.editor.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.keymap.Keymap
import com.intellij.openapi.keymap.KeymapManagerListener
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.JBColor
@@ -82,6 +98,8 @@ class PromptPanel(
private val onSend: (String, List<PromptPartDto>) -> Unit,
private val onAbort: () -> Unit,
private val onEnhance: (String, (Result<String>) -> Unit) -> Unit,
private val onMentions: (String) -> List<PromptPartDto> = { emptyList() },
private val completion: KiloPromptCompletionProvider? = null,
private val selection: SessionSelection? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext, UiDataProvider {
@@ -92,6 +110,9 @@ class PromptPanel(
private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java)
private val SHIELD_FILLED_ICON: Icon = IconLoader.getIcon("/icons/shield-filled.svg", PromptPanel::class.java)
private val WAND_ICON: Icon = IconLoader.getIcon("/icons/wand-sparkles.svg", PromptPanel::class.java)
private val MENTION_KEY = DefaultLanguageHighlighterColors.METADATA
private val COMMAND_KEY = DefaultLanguageHighlighterColors.KEYWORD
private val INVALID_KEY = CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES
}
val mode = ModePicker()
@@ -114,8 +135,13 @@ class PromptPanel(
)
}
private val attachments = mutableListOf<PromptAttachment>()
private val highlighters = mutableListOf<RangeHighlighter>()
private val strip = PromptAttachmentStrip(project) { removeAttachment(it) }
private var bus: MessageBusConnection? = null
private var lookupBus: MessageBusConnection? = null
private var completionAction: AnAction? = null
private var completionTarget: JComponent? = null
private var mentionCaret = false
private var autoApprove = false
private var attachment = true
private var submitting = false
@@ -126,7 +152,7 @@ class PromptPanel(
}
}
private val editor = PromptEditorTextField(project, this, selection).apply {
private val editor = PromptEditorTextField(project, this, completion, selection).apply {
border = JBUI.Borders.empty()
setFontInheritedFromLAF(false)
setPlaceholder(placeholder())
@@ -148,8 +174,20 @@ class PromptPanel(
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
ed.scrollPane.horizontalScrollBarPolicy =
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
installCompletionShortcut(ed)
completion?.let { MentionNavigator(ed, it).install() }
installFileDrop(ed.contentComponent, "editor")
installFileDrop(ed.scrollPane, "scroll")
syncHighlights()
ed.caretModel.addCaretListener(object : CaretListener {
override fun caretPositionChanged(e: CaretEvent) {
val provider = completion ?: return
val inside = provider.inside(ed.document.text, ed.caretModel.offset)
if (mentionCaret == inside) return
mentionCaret = inside
syncHighlights()
}
})
ed.contentComponent.addFocusListener(object : FocusAdapter() {
override fun focusGained(e: FocusEvent) {
repaint()
@@ -218,6 +256,8 @@ class PromptPanel(
override fun documentChanged(e: DocumentEvent) {
invalidateEnhancement()
syncEditorHeight()
triggerCompletion(e)
syncHighlights()
onChange()
}
})
@@ -262,6 +302,7 @@ class PromptPanel(
@RequiresEdt
fun setReady(value: Boolean) {
ready = value
if (value) completion?.prewarm()
if (!value) invalidateEnhancement() else syncEnhance()
}
@@ -334,14 +375,58 @@ class PromptPanel(
editor.background = style.editorScheme.defaultBackground
syncEditorHeight()
syncAutoApprove()
syncHighlights()
}
@RequiresEdt
fun refreshHighlights() {
syncHighlights()
}
@RequiresEdt
fun clear() {
editor.text = ""
attachments.clear()
completion?.clearMentions()
completion?.prewarm()
strip.clear()
syncEditorHeight()
syncHighlights()
}
@RequiresEdt
private fun syncHighlights() {
val provider = completion ?: return
val ed = editor.getEditor(false) ?: return
highlighters.forEach(ed.markupModel::removeHighlighter)
highlighters.clear()
val length = ed.document.textLength
val text = ed.document.text
val caret = ed.caretModel.offset
provider.validate(text, caret) {
ApplicationManager.getApplication().invokeLater {
if (!project.isDisposed) refreshHighlights()
}
}
provider.highlights(text, caret).forEach { item ->
val start = item.start.coerceIn(0, length)
val end = item.end.coerceIn(start, length)
if (start == end) return@forEach
highlighters += ed.markupModel.addRangeHighlighter(
key(item.kind),
start,
end,
HighlighterLayer.SYNTAX + 1,
HighlighterTargetArea.EXACT_RANGE,
)
}
mentionCaret = provider.inside(text, caret)
}
private fun key(kind: KiloPromptCompletionProvider.HighlightKind): TextAttributesKey = when (kind) {
KiloPromptCompletionProvider.HighlightKind.MENTION -> MENTION_KEY
KiloPromptCompletionProvider.HighlightKind.COMMAND -> COMMAND_KEY
KiloPromptCompletionProvider.HighlightKind.INVALID -> INVALID_KEY
}
@RequiresEdt
@@ -360,6 +445,7 @@ class PromptPanel(
super.addNotify()
bindRoot()
bindKeymap()
bindLookup()
}
override fun removeNotify() {
@@ -367,6 +453,9 @@ class PromptPanel(
root = null
bus?.disconnect()
bus = null
lookupBus?.disconnect()
lookupBus = null
uninstallCompletionShortcut()
super.removeNotify()
}
@@ -432,11 +521,13 @@ class PromptPanel(
ApplicationManager.getApplication().executeOnPooledThread {
try {
val files = items.map { it.part() }
val mentioned = onMentions(txt)
ApplicationManager.getApplication().invokeLater {
submitting = false
if (project.isDisposed) return@invokeLater
LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, files))} src=$src busy=$busy" }
onSend(txt, files)
val parts = files + mentioned
LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, parts))} src=$src busy=$busy" }
onSend(txt, parts)
}
} catch (e: Exception) {
ApplicationManager.getApplication().invokeLater {
@@ -449,6 +540,61 @@ class PromptPanel(
}
}
private fun triggerCompletion(e: DocumentEvent) {
if (project.isDisposed) return
val value = e.newFragment.toString()
if (value.length != 1) return
val text = editor.text
val offset = e.offset + value.length
val popup = value == "@" || (value == "/" && text.take(offset).trim() == "/")
if (!popup) return
ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater
editor.getEditor(false)?.let(::showCompletion)
}
}
@RequiresEdt
@Suppress("UnstableApiUsage")
private fun showCompletion(ed: com.intellij.openapi.editor.Editor) {
// Run completion synchronously and locally on this frontend-only editor. The public entry
// points (ACTION_CODE_COMPLETION, AutoPopupController.scheduleAutoPopup) route through
// split-mode RD/autopopup machinery that targets a backend-shared editor: in split mode they
// either dispatch to the backend (which has no editor and asserts) or get cancelled before the
// popup shows. CodeCompletionHandlerBase is public (not @ApiStatus.Internal) and is the only
// API that drives the completion engine directly against this local editor.
CodeCompletionHandlerBase.createHandler(CompletionType.BASIC, true, false, true)
.invokeCompletion(project, ed, 1)
}
@RequiresEdt
private fun installCompletionShortcut(ed: com.intellij.openapi.editor.Editor) {
if (completion == null) return
val base = ActionManager.getInstance().getAction(IdeActions.ACTION_CODE_COMPLETION) ?: return
val action = completionAction ?: object : DumbAwareAction(KiloBundle.message("prompt.completion.action")) {
override fun actionPerformed(e: AnActionEvent) {
editor.getEditor(false)?.let(::showCompletion)
}
}.also { completionAction = it }
uninstallCompletionShortcut()
val target = ed.contentComponent
action.registerCustomShortcutSet(base.shortcutSet, target)
completionTarget = target
}
@RequiresEdt
private fun refreshCompletionShortcut() {
val ed = editor.getEditor(false) ?: return
installCompletionShortcut(ed)
}
@RequiresEdt
private fun uninstallCompletionShortcut() {
val target = completionTarget ?: return
completionAction?.unregisterCustomShortcutSet(target)
completionTarget = null
}
@RequiresEdt
private fun addAttachment(item: PromptAttachment) {
if (!attachment && PromptAttachmentExtractor.media(item.mime)) {
@@ -566,6 +712,7 @@ class PromptPanel(
override fun activeKeymapChanged(keymap: Keymap?) {
editor.setPlaceholder(placeholder())
syncTooltip()
refreshCompletionShortcut()
}
override fun shortcutsChanged(keymap: Keymap, actionIds: Collection<String>, fromSettings: Boolean) {
@@ -574,10 +721,27 @@ class PromptPanel(
editor.setPlaceholder(placeholder())
syncTooltip()
}
if (IdeActions.ACTION_CODE_COMPLETION in actionIds) refreshCompletionShortcut()
}
})
}
@RequiresEdt
private fun bindLookup() {
if (lookupBus != null) return
val connection = project.messageBus.connect()
lookupBus = connection
connection.subscribe(LookupManagerListener.TOPIC, LookupManagerListener { _, next ->
// LookupPresentation/LookupPositionStrategy are @ApiStatus.Experimental (no stable API
// forces the lookup above the caret). LookupEx is the public lookup interface.
val lookup = next as? LookupEx ?: return@LookupManagerListener
if (lookup.editor !== editor.getEditor(false)) return@LookupManagerListener
lookup.presentation = LookupPresentation.Builder(lookup.presentation)
.withPositionStrategy(LookupPositionStrategy.ONLY_ABOVE)
.build()
})
}
@RequiresEdt
private fun syncTooltip() {
button.toolTipText = tooltip()
@@ -677,59 +841,7 @@ class PromptPanel(
syncEditorHeight()
}
private inner class SendButton : JButton(), UiDataProvider {
private var over = false
init {
iconButton(this)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun getPreferredSize() = JBUI.size(
SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE,
SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE,
)
override fun uiDataSnapshot(sink: DataSink) {
sink.set(PromptDataKeys.SEND, this@PromptPanel)
}
override fun getMinimumSize() = preferredSize
override fun getMaximumSize() = preferredSize
override fun paintComponent(g: Graphics) {
if (over) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
super.paintComponent(g)
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
private inner class AutoApproveButton : JButton() {
private inner abstract class PromptButton : JButton() {
private var over = false
init {
@@ -779,4 +891,12 @@ class PromptPanel(
}
}
private inner class SendButton : PromptButton(), UiDataProvider {
override fun uiDataSnapshot(sink: DataSink) {
sink.set(PromptDataKeys.SEND, this@PromptPanel)
}
}
private inner class AutoApproveButton : PromptButton()
}
@@ -0,0 +1,36 @@
package ai.kilocode.client.session.ui.prompt
data class SlashAction(
val name: String,
val description: String,
val hints: List<String> = emptyList(),
val action: () -> Unit,
) {
data class Spec(
val name: String,
val descriptionKey: String,
val hints: List<String> = emptyList(),
)
companion object {
val NEW = Spec("new", "prompt.slash.new", listOf("clear"))
val SESSIONS = Spec("sessions", "prompt.slash.sessions", listOf("history", "resume", "continue"))
val MODELS = Spec("models", "prompt.slash.models")
val AGENTS = Spec("agents", "prompt.slash.agents", listOf("modes"))
val VARIANT = Spec("variant", "prompt.slash.variant", listOf("reasoning", "variants", "thinking"))
val COMPACT = Spec("compact", "prompt.slash.compact", listOf("smol", "condense"))
val SETTINGS = Spec("settings", "prompt.slash.settings")
val HELP = Spec("help", "prompt.slash.help")
val ALL = listOf(
NEW,
SESSIONS,
MODELS,
AGENTS,
VARIANT,
COMPACT,
SETTINGS,
HELP,
)
}
}
@@ -114,11 +114,20 @@ class MessageView(
fun upsertPart(content: Content) {
if (content is StepFinish) return
if (isHidden(content)) {
if (isPromptMention(content)) syncPromptMentions()
// Remove any stale view for this content so it disappears when suppressed
val id = aliases.remove(content.id)
sources.remove(content.id)
val stale = if (id == null) parts.remove(content.id) else null
if (stale != null) {
if (stale is PromptAttachmentView) {
stale.remove(content.id)
if (!stale.isEmpty()) {
refresh()
return
}
attachments = null
}
detach(stale)
remove(stale)
Disposer.dispose(stale)
@@ -262,6 +271,7 @@ class MessageView(
* pending/running question tool part linked to the active question.
*/
private fun isHidden(content: Content): Boolean {
if (isPromptMention(content)) return true
if (content !is Tool) return false
if (role == SessionUiStyle.View.Message.USER_ROLE && content.name == "read") return true
if (content.name == "todoread") return true
@@ -307,11 +317,24 @@ class MessageView(
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
ViewFactory.createUser(content, openFile, openUrl, selection, repo, promptMentions(msg)) { openAttachment(msg.info.id, it) }
} else {
ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
}
private fun syncPromptMentions() {
val mentions = promptMentions(msg)
for (view in parts.values) {
if (view is PromptView) view.setMentions(mentions)
}
}
private fun isPromptMention(content: Content): Boolean {
if (role != SessionUiStyle.View.Message.USER_ROLE) return false
if (content !is FileAttachment) return false
return content.source != null && content.mime.lowercase().startsWith("text/plain")
}
/** Append a streaming delta to the renderer for [contentId]. */
@RequiresEdt
fun appendDelta(contentId: String, delta: String): Boolean {
@@ -0,0 +1,71 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.Message
data class PromptMention(
val token: String,
val path: String,
val start: Int,
val end: Int,
val attachment: FileAttachment? = null,
)
fun promptMentions(msg: Message): List<PromptMention> = msg.parts.values.mapNotNull { part ->
if (part !is FileAttachment) return@mapNotNull null
if (!part.mime.lowercase().startsWith("text/plain")) return@mapNotNull null
val source = part.source ?: return@mapNotNull null
val path = source.path?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
PromptMention(
token = source.text.value,
path = path,
start = source.text.start.toInt(),
end = source.text.end.toInt(),
attachment = part,
)
}
fun linkifyMentions(text: String, mentions: List<PromptMention>): String {
if (mentions.isEmpty()) return text
val ranges = mutableListOf<Pair<IntRange, PromptMention>>()
val used = mutableListOf<IntRange>()
for (mention in mentions) {
val range = exact(text, mention)?.takeUnless { overlaps(it, used) }
?: fallback(text, mention, used)
?: continue
ranges.add(range to mention)
used.add(range)
}
val out = StringBuilder(text)
for ((range, mention) in ranges.sortedByDescending { it.first.first }) {
val link = link(mention)
out.replace(range.first, range.last + 1, link)
}
return out.toString()
}
private fun exact(text: String, mention: PromptMention): IntRange? {
if (mention.start < 0 || mention.end > text.length || mention.start >= mention.end) return null
if (text.substring(mention.start, mention.end) != mention.token) return null
return mention.start until mention.end
}
private fun fallback(text: String, mention: PromptMention, used: List<IntRange>): IntRange? {
if (mention.token.isEmpty()) return null
var at = text.indexOf(mention.token)
while (at >= 0) {
val range = at until at + mention.token.length
if (!overlaps(range, used)) return range
at = text.indexOf(mention.token, at + 1)
}
return null
}
private fun overlaps(range: IntRange, used: List<IntRange>): Boolean =
used.any { range.first <= it.last && it.first <= range.last }
private fun link(mention: PromptMention): String {
val text = mention.token.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
val href = mention.path.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
return "[$text]($href)"
}
@@ -1,27 +1,83 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.model.Content
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.util.ui.JBUI
class PromptView(
text: Text,
private val openFile: (String) -> Unit = {},
private val openAttachment: (FileAttachment) -> Unit = {},
openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
mentions: List<PromptMention> = emptyList(),
) : TextView(text, transparent = true, openUrl = openUrl, selection = selection) {
private var mentions = mentions
private val buffer = StringBuilder(text.content)
init {
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
sync()
}
override fun update(content: Content) {
if (content !is Text) return
buffer.clear()
buffer.append(content.content)
sync()
}
override fun appendDelta(delta: String) {
if (delta.isEmpty()) return
buffer.append(delta)
sync()
}
fun setMentions(list: List<PromptMention>) {
if (mentions == list) return
mentions = list
sync()
}
override fun onLink(href: String) {
val mention = mentions.firstOrNull { it.path == href || path(it.path) == href }
if (mention != null) {
mention.attachment?.let {
openAttachment(it)
return
}
openFile(mention.path)
return
}
super.onLink(href)
}
override fun applyStyle(style: SessionEditorStyle) {
super.applyStyle(style)
val color = style.editorScheme.getAttributes(DefaultLanguageHighlighterColors.METADATA)?.foregroundColor
if (color == null || md.linkColor == color) return
md.linkColor = color
}
override fun styleFont(style: SessionEditorStyle) = style.editorFont
override fun styleBackground(style: SessionEditorStyle) = style.editorBackground
private fun sync() {
md.set(linkifyMentions(buffer.toString(), mentions))
refresh()
}
private fun path(value: String) = value.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
override fun dumpLabel() = "PromptView#$contentId"
}
@@ -20,7 +20,7 @@ import javax.swing.JButton
open class TextView(
text: Text,
transparent: Boolean = true,
openUrl: (String) -> Unit = {},
private val openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
) : PartView() {
@@ -35,7 +35,7 @@ open class TextView(
isOpaque = false
Disposer.register(this, md)
md.opaque = !transparent
md.addLinkListener { openUrl(it.href) }
md.addLinkListener { onLink(it.href) }
applyStyle(SessionEditorStyle.current())
add(md.component, BorderLayout.CENTER)
add(toolbar, BorderLayout.SOUTH)
@@ -78,8 +78,12 @@ open class TextView(
/** Current markdown source — used by tests to assert rendered content. */
fun markdown(): String = md.markdown()
internal fun simulateLink(href: String) = md.simulateLink(href)
internal fun contentOpaque() = md.opaque
protected open fun onLink(href: String) = openUrl(href)
override fun applyStyle(style: SessionEditorStyle) {
val font = styleFont(style)
val bg = styleBackground(style)
@@ -100,7 +104,7 @@ open class TextView(
protected open fun styleBackground(style: SessionEditorStyle) = style.editorBackground
private fun refresh() {
protected fun refresh() {
revalidate()
repaint()
}
@@ -70,9 +70,10 @@ object ViewFactory {
openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
repo: String? = null,
mentions: List<PromptMention> = emptyList(),
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
): PartView = when (content) {
is Text -> PromptView(content, openUrl = openUrl, selection = selection)
is Text -> PromptView(content, openFile = openFile, openAttachment = openAttachment, openUrl = openUrl, selection = selection, mentions = mentions)
else -> create(content, openFile, openUrl, selection, repo, openAttachment)
}
@@ -136,10 +136,10 @@ session.header.context.used={0} / {1} tokens used
session.header.context.reserved={0} reserved for output
session.header.context.available={0} available
prompt.placeholder=Type a message...
prompt.placeholder.with.shortcuts=Type a message... ({0} to send, {1} for new line)
prompt.placeholder.with.send=Type a message... ({0} to send)
prompt.placeholder.with.newline=Type a message... ({0} for new line)
prompt.placeholder=Type here; use / or @, drop/paste files
prompt.placeholder.with.shortcuts=Type here; use / or @, drop/paste files; {0} send, {1} newline
prompt.placeholder.with.send=Type here; use / or @, drop/paste files; {0} send
prompt.placeholder.with.newline=Type here; use / or @, drop/paste files; {0} newline
prompt.button.send=Send
prompt.button.stop=Stop
prompt.button.send.tooltip.stop=To stop, press {0}
@@ -163,10 +163,26 @@ 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.
prompt.action.enhance.failed=Failed to enhance prompt
prompt.action.enhance.failed.description=The configured small model could not enhance this prompt.
prompt.completion.action=Kilo Prompt Completion
prompt.completion.noMatches=No matches
prompt.mention.indexing=Indexing project files. File mentions will be available soon.
prompt.mention.gitChanges=Attach current git changes
prompt.mention.goto=Go to Mentioned File
prompt.mention.unresolved=Cannot resolve file "{0}" in the workspace
prompt.slash.new=Start a new session
prompt.slash.sessions=Open session history
prompt.slash.models=Choose a model
prompt.slash.agents=Choose an agent
prompt.slash.variant=Choose reasoning variant
prompt.slash.compact=Compact this session
prompt.slash.settings=Open Kilo settings
prompt.slash.help=Open Kilo documentation
prompt.action.autoApprove.enable=Enable auto-approve
prompt.action.autoApprove.disable=Disable auto-approve
prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically.
prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically.
session.editor.undo=Kilo Session Undo
session.editor.redo=Kilo Session Redo
mode.picker.tooltip=Select mode
mode.picker.deprecated=deprecated
model.picker.tooltip=Select model
@@ -24,7 +24,7 @@ session.permission.title=طلب إذن
session.permission.meta=الأداة: {0} • الأنماط: {1}
session.permission.allow=سماح
session.permission.deny=رفض
session.question.dismiss=رفض
session.question.dismiss=إغلاق
session.status.considering=جار التفكير في الخطوات التالية…
session.status.thinking=جار التفكير…
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} رمز مستخدم
session.header.context.reserved={0} محجوز للإخراج
session.header.context.available={0} متاح
prompt.placeholder=اكتب رسالة…
prompt.placeholder.with.shortcuts=اكتب رسالة… ({0} للإرسال، {1} لسطر جديد)
prompt.placeholder.with.send=اكتب رسالة… ({0} للإرسال)
prompt.placeholder.with.newline=اكتب رسالة… ({0} لسطر جديد)
prompt.placeholder=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات
prompt.placeholder.with.shortcuts=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات؛ {0} إرسال، {1} سطر جديد
prompt.placeholder.with.send=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات؛ {0} إرسال
prompt.placeholder.with.newline=اكتب هنا؛ استخدم / أو @، وأسقط/الصق الملفات؛ {0} سطر جديد
prompt.button.send=إرسال
prompt.button.stop=إيقاف
prompt.button.send.tooltip.stop=للإيقاف، اضغط {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokena korišteno
session.header.context.reserved={0} rezervisano za izlaz
session.header.context.available={0} dostupno
prompt.placeholder=Upišite poruku…
prompt.placeholder.with.shortcuts=Upišite poruku… ({0} za slanje, {1} za novi red)
prompt.placeholder.with.send=Upišite poruku… ({0} za slanje)
prompt.placeholder.with.newline=Upišite poruku… ({0} za novi red)
prompt.placeholder=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke
prompt.placeholder.with.shortcuts=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke; {0} pošalji, {1} novi red
prompt.placeholder.with.send=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke; {0} pošalji
prompt.placeholder.with.newline=Pišite ovdje; koristite / ili @, ispustite/zalijepite datoteke; {0} novi red
prompt.button.send=Pošalji
prompt.button.stop=Zaustavi
prompt.button.send.tooltip.stop=Da zaustavite, pritisnite {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokens brugt
session.header.context.reserved={0} reserveret til output
session.header.context.available={0} tilgængelige
prompt.placeholder=Skriv en besked…
prompt.placeholder.with.shortcuts=Skriv en besked… ({0} for at sende, {1} for ny linje)
prompt.placeholder.with.send=Skriv en besked… ({0} for at sende)
prompt.placeholder.with.newline=Skriv en besked… ({0} for ny linje)
prompt.placeholder=Skriv her; brug / eller @, slip/indsæt filer
prompt.placeholder.with.shortcuts=Skriv her; brug / eller @, slip/indsæt filer; {0} send, {1} ny linje
prompt.placeholder.with.send=Skriv her; brug / eller @, slip/indsæt filer; {0} send
prompt.placeholder.with.newline=Skriv her; brug / eller @, slip/indsæt filer; {0} ny linje
prompt.button.send=Send
prompt.button.stop=Stop
prompt.button.send.tooltip.stop=Tryk på {0} for at stoppe
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} Token verwendet
session.header.context.reserved={0} für Ausgabe reserviert
session.header.context.available={0} verfügbar
prompt.placeholder=Nachricht eingeben…
prompt.placeholder.with.shortcuts=Nachricht eingeben… ({0} zum Senden, {1} für neue Zeile)
prompt.placeholder.with.send=Nachricht eingeben… ({0} zum Senden)
prompt.placeholder.with.newline=Nachricht eingeben… ({0} für neue Zeile)
prompt.placeholder=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen
prompt.placeholder.with.shortcuts=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen; {0} senden, {1} neue Zeile
prompt.placeholder.with.send=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen; {0} senden
prompt.placeholder.with.newline=Hier tippen; / oder @ verwenden, Dateien ablegen/einfügen; {0} neue Zeile
prompt.button.send=Senden
prompt.button.stop=Stopp
prompt.button.send.tooltip.stop=Zum Stoppen drücken Sie {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokens utilizados
session.header.context.reserved={0} reservados para la salida
session.header.context.available={0} disponibles
prompt.placeholder=Escribe un mensaje…
prompt.placeholder.with.shortcuts=Escribe un mensaje… ({0} para enviar, {1} para nueva línea)
prompt.placeholder.with.send=Escribe un mensaje… ({0} para enviar)
prompt.placeholder.with.newline=Escribe un mensaje… ({0} para nueva línea)
prompt.placeholder=Escribe aquí; usa / o @, suelta/pega archivos
prompt.placeholder.with.shortcuts=Escribe aquí; usa / o @, suelta/pega archivos; {0} enviar, {1} nueva línea
prompt.placeholder.with.send=Escribe aquí; usa / o @, suelta/pega archivos; {0} enviar
prompt.placeholder.with.newline=Escribe aquí; usa / o @, suelta/pega archivos; {0} nueva línea
prompt.button.send=Enviar
prompt.button.stop=Detener
prompt.button.send.tooltip.stop=Para detener, presiona {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokens utilisés
session.header.context.reserved={0} réservés pour la sortie
session.header.context.available={0} disponibles
prompt.placeholder=Saisir un message…
prompt.placeholder.with.shortcuts=Saisir un message… ({0} pour envoyer, {1} pour nouvelle ligne)
prompt.placeholder.with.send=Saisir un message… ({0} pour envoyer)
prompt.placeholder.with.newline=Saisir un message… ({0} pour nouvelle ligne)
prompt.placeholder=Tapez ici ; utilisez / ou @, déposez/collez des fichiers
prompt.placeholder.with.shortcuts=Tapez ici ; utilisez / ou @, déposez/collez des fichiers ; {0} envoyer, {1} nouvelle ligne
prompt.placeholder.with.send=Tapez ici ; utilisez / ou @, déposez/collez des fichiers ; {0} envoyer
prompt.placeholder.with.newline=Tapez ici ; utilisez / ou @, déposez/collez des fichiers ; {0} nouvelle ligne
prompt.button.send=Envoyer
prompt.button.stop=Arrêter
prompt.button.send.tooltip.stop=Pour arrêter, appuyez sur {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1}トークン使用中
session.header.context.reserved={0}は出力用に予約済み
session.header.context.available={0}利用可能
prompt.placeholder=メッセージを入力…
prompt.placeholder.with.shortcuts=メッセージを入力…({0}で送信、{1}で改行
prompt.placeholder.with.send=メッセージを入力…({0}で送信
prompt.placeholder.with.newline=メッセージを入力…({0}で改行
prompt.placeholder=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け
prompt.placeholder.with.shortcuts=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け;{0}で送信、{1}で改行
prompt.placeholder.with.send=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け;{0}で送信
prompt.placeholder.with.newline=ここに入力;/ または @ を使用、ファイルをドロップ/貼り付け;{0}で改行
prompt.button.send=送信
prompt.button.stop=停止
prompt.button.send.tooltip.stop=停止するには{0}を押してください
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} 토큰 사용 중
session.header.context.reserved={0} 출력용 예약됨
session.header.context.available={0} 사용 가능
prompt.placeholder=메시지를 입력하세요…
prompt.placeholder.with.shortcuts=메시지를 입력하세요… ({0} 전송, {1} 새 줄)
prompt.placeholder.with.send=메시지를 입력하세요… ({0} 전송)
prompt.placeholder.with.newline=메시지를 입력하세요… ({0} 새 줄)
prompt.placeholder=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기
prompt.placeholder.with.shortcuts=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기; {0} 전송, {1} 새 줄
prompt.placeholder.with.send=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기; {0} 전송
prompt.placeholder.with.newline=여기에 입력; / 또는 @ 사용, 파일 드롭/붙여넣기; {0} 새 줄
prompt.button.send=전송
prompt.button.stop=정지
prompt.button.send.tooltip.stop=정지하려면 {0}을 누르세요
@@ -89,7 +89,7 @@ model.picker.free=무료
model.picker.reset=모델을 기본값으로 재설정
reasoning.picker.tooltip=추론 노력 선택
history.tab.local=
history.tab.local=
history.tab.cloud=클라우드
history.back=뒤로
history.search.placeholder=세션 검색
@@ -98,8 +98,8 @@ history.loading=로딩 중…
history.untitled=제목 없음
history.delete.text=삭제
history.delete.confirm.title=세션을 삭제하시겠습니까?
history.delete.confirm.message= 기록에서 "{0}"을 삭제하시겠습니까?
history.delete.confirm.message.multiple= 기록에서 {0}개의 세션을 삭제하시겠습니까?
history.delete.confirm.message= 기록에서 "{0}"을 삭제하시겠습니까?
history.delete.confirm.message.multiple= 기록에서 {0}개의 세션을 삭제하시겠습니까?
history.rename.title=세션 이름 바꾸기
history.rename.prompt=새 세션 이름:
history.cloud.load.more=더 불러오기
@@ -115,7 +115,7 @@ history.time.hours={0}시간 전
history.time.days={0}일 전
history.time.months={0}개월 전
history.time.years={0}년 전
history.error.local= 기록을 로드하지 못했습니다
history.error.local= 기록을 로드하지 못했습니다
history.error.cloud=클라우드 기록을 로드하지 못했습니다
history.error.local.delete=세션을 삭제하지 못했습니다
history.error.local.rename=세션의 이름을 바꾸지 못했습니다
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokens gebruikt
session.header.context.reserved={0} gereserveerd voor uitvoer
session.header.context.available={0} beschikbaar
prompt.placeholder=Typ een bericht…
prompt.placeholder.with.shortcuts=Typ een bericht… ({0} om te verzenden, {1} voor nieuwe regel)
prompt.placeholder.with.send=Typ een bericht… ({0} om te verzenden)
prompt.placeholder.with.newline=Typ een bericht… ({0} voor nieuwe regel)
prompt.placeholder=Typ hier; gebruik / of @, sleep/plak bestanden
prompt.placeholder.with.shortcuts=Typ hier; gebruik / of @, sleep/plak bestanden; {0} verzenden, {1} nieuwe regel
prompt.placeholder.with.send=Typ hier; gebruik / of @, sleep/plak bestanden; {0} verzenden
prompt.placeholder.with.newline=Typ hier; gebruik / of @, sleep/plak bestanden; {0} nieuwe regel
prompt.button.send=Verzenden
prompt.button.stop=Stoppen
prompt.button.send.tooltip.stop=Druk op {0} om te stoppen
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokens brukt
session.header.context.reserved={0} reservert for utdata
session.header.context.available={0} tilgjengelig
prompt.placeholder=Skriv en melding…
prompt.placeholder.with.shortcuts=Skriv en melding… ({0} for å sende, {1} for ny linje)
prompt.placeholder.with.send=Skriv en melding… ({0} for å sende)
prompt.placeholder.with.newline=Skriv en melding… ({0} for ny linje)
prompt.placeholder=Skriv her; bruk / eller @, slipp/lim inn filer
prompt.placeholder.with.shortcuts=Skriv her; bruk / eller @, slipp/lim inn filer; {0} send, {1} ny linje
prompt.placeholder.with.send=Skriv her; bruk / eller @, slipp/lim inn filer; {0} send
prompt.placeholder.with.newline=Skriv her; bruk / eller @, slipp/lim inn filer; {0} ny linje
prompt.button.send=Send
prompt.button.stop=Stopp
prompt.button.send.tooltip.stop=Trykk {0} for å stoppe
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokenów użito
session.header.context.reserved={0} zarezerwowane dla wyjścia
session.header.context.available={0} dostępne
prompt.placeholder=Wpisz wiadomość…
prompt.placeholder.with.shortcuts=Wpisz wiadomość… ({0} aby wyslać, {1} nowa linia)
prompt.placeholder.with.send=Wpisz wiadomość… ({0} aby wyslać)
prompt.placeholder.with.newline=Wpisz wiadomość… ({0} nowa linia)
prompt.placeholder=Pisz tutaj; użyj / lub @, upuść/wklej pliki
prompt.placeholder.with.shortcuts=Pisz tutaj; użyj / lub @, upuść/wklej pliki; {0} wyślij, {1} nowa linia
prompt.placeholder.with.send=Pisz tutaj; użyj / lub @, upuść/wklej pliki; {0} wyślij
prompt.placeholder.with.newline=Pisz tutaj; użyj / lub @, upuść/wklej pliki; {0} nowa linia
prompt.button.send=Wyślij
prompt.button.stop=Zatrzymaj
prompt.button.send.tooltip.stop=Aby zatrzymać, naciśnij {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} tokens utilizados
session.header.context.reserved={0} reservados para saída
session.header.context.available={0} disponíveis
prompt.placeholder=Digite uma mensagem…
prompt.placeholder.with.shortcuts=Digite uma mensagem… ({0} para enviar, {1} para nova linha)
prompt.placeholder.with.send=Digite uma mensagem… ({0} para enviar)
prompt.placeholder.with.newline=Digite uma mensagem… ({0} para nova linha)
prompt.placeholder=Digite aqui; use / ou @, solte/cole arquivos
prompt.placeholder.with.shortcuts=Digite aqui; use / ou @, solte/cole arquivos; {0} enviar, {1} nova linha
prompt.placeholder.with.send=Digite aqui; use / ou @, solte/cole arquivos; {0} enviar
prompt.placeholder.with.newline=Digite aqui; use / ou @, solte/cole arquivos; {0} nova linha
prompt.button.send=Enviar
prompt.button.stop=Parar
prompt.button.send.tooltip.stop=Para parar, pressione {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} токенов использовано
session.header.context.reserved={0} зарезервировано для вывода
session.header.context.available={0} доступно
prompt.placeholder=Введите сообщение…
prompt.placeholder.with.shortcuts=Введите сообщение… ({0} — отправить, {1} — новая строка)
prompt.placeholder.with.send=Введите сообщение… ({0} — отправить)
prompt.placeholder.with.newline=Введите сообщение… ({0} — новая строка)
prompt.placeholder=Пишите здесь; используйте / или @, перетащите/вставьте файлы
prompt.placeholder.with.shortcuts=Пишите здесь; используйте / или @, перетащите/вставьте файлы; {0} — отправить, {1} — новая строка
prompt.placeholder.with.send=Пишите здесь; используйте / или @, перетащите/вставьте файлы; {0} — отправить
prompt.placeholder.with.newline=Пишите здесь; используйте / или @, перетащите/вставьте файлы; {0} — новая строка
prompt.button.send=Отправить
prompt.button.stop=Остановить
prompt.button.send.tooltip.stop=Для остановки нажмите {0}
@@ -69,10 +69,10 @@ session.header.context.used=ใช้แล้ว {0} / {1} โทเคน
session.header.context.reserved={0} สำรองไว้สำหรับเอาต์พุต
session.header.context.available={0} พร้อมใช้งาน
prompt.placeholder=พิมพ์ข้อความ…
prompt.placeholder.with.shortcuts=พิมพ์ข้อความ… ({0} เพื่อส่ง {1} สำหรับบรรทัดใหม่)
prompt.placeholder.with.send=พิมพ์ข้อความ… ({0} เพื่อส่ง)
prompt.placeholder.with.newline=พิมพ์ข้อความ… ({0} สำหรับบรรทัดใหม่)
prompt.placeholder=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์
prompt.placeholder.with.shortcuts=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์; {0} ส่ง, {1} บรรทัดใหม่
prompt.placeholder.with.send=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์; {0} ส่ง
prompt.placeholder.with.newline=พิมพ์ที่นี่; ใช้ / หรือ @, ลากวาง/วางไฟล์; {0} บรรทัดใหม่
prompt.button.send=ส่ง
prompt.button.stop=หยุด
prompt.button.send.tooltip.stop=กด {0} เพื่อหยุด
@@ -14,7 +14,7 @@ feedback.dialog.message=Geri bildiriminizi almaktan veya yaşadığınız sorunl
feedback.dialog.github=GitHub'da sorun bildirin
feedback.dialog.discord=Discord topluluğumuza katılın
feedback.dialog.support=Müşteri Desteği
session.scroll.bottom=En alta kaýr
session.scroll.bottom=En alta kaydır
session.copy.hover=Kopyala
session.copy.copied=Kopyalandı
session.tab.new=Yeni oturum
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} jeton kullanıldı
session.header.context.reserved={0} çıkış için ayrıldı
session.header.context.available={0} kullanılabilir
prompt.placeholder=Bir mesaj yazın
prompt.placeholder.with.shortcuts=Bir mesaj yazın… ({0} göndermek için, {1} yeni satır için)
prompt.placeholder.with.send=Bir mesaj yazın… ({0} göndermek için)
prompt.placeholder.with.newline=Bir mesaj yazın… ({0} yeni satır için)
prompt.placeholder=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın
prompt.placeholder.with.shortcuts=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın; {0} gönder, {1} yeni satır
prompt.placeholder.with.send=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın; {0} gönder
prompt.placeholder.with.newline=Buraya yazın; / veya @ kullanın, dosyaları bırakın/yapıştırın; {0} yeni satır
prompt.button.send=Gönder
prompt.button.stop=Durdur
prompt.button.send.tooltip.stop=Durdurmak için {0} basın
@@ -52,7 +52,7 @@ session.error.compact=Помилка стиснення сесії
session.error.unknown=Невідома помилка
session.header.tokens=Токени
session.header.tokens.description=Токени, використані останній відповідю асистента: вхід, вихід, запис у кеш і читання з кешу.
session.header.tokens.description=Токени, використані останньою відповіддю асистента: вхід, вихід, запис у кеш і читання з кешу.
session.header.input=вхід {0}
session.header.output=вихід {0}
session.header.cache.write=запис у кеш {0}
@@ -69,10 +69,10 @@ session.header.context.used={0} / {1} токенів використано
session.header.context.reserved={0} зарезервовано для виходу
session.header.context.available={0} доступно
prompt.placeholder=Введіть повідомлення…
prompt.placeholder.with.shortcuts=Введіть повідомлення… ({0} — надіслати, {1} — новий рядок)
prompt.placeholder.with.send=Введіть повідомлення… ({0} — надіслати)
prompt.placeholder.with.newline=Введіть повідомлення… ({0} — новий рядок)
prompt.placeholder=Пишіть тут; використовуйте / або @, перетягніть/вставте файли
prompt.placeholder.with.shortcuts=Пишіть тут; використовуйте / або @, перетягніть/вставте файли; {0} — надіслати, {1} — новий рядок
prompt.placeholder.with.send=Пишіть тут; використовуйте / або @, перетягніть/вставте файли; {0} — надіслати
prompt.placeholder.with.newline=Пишіть тут; використовуйте / або @, перетягніть/вставте файли; {0} — новий рядок
prompt.button.send=Надіслати
prompt.button.stop=Зупинити
prompt.button.send.tooltip.stop=Щоб зупинити, натисніть {0}
@@ -69,10 +69,10 @@ session.header.context.used=已使用 {0} / {1} 令牌
session.header.context.reserved={0} 为输出预留
session.header.context.available={0} 可用
prompt.placeholder=输入消息…
prompt.placeholder.with.shortcuts=输入消息…({0} 发送,{1} 换行
prompt.placeholder.with.send=输入消息…({0} 发送
prompt.placeholder.with.newline=输入消息…({0} 换行
prompt.placeholder=在此输入;使用 / 或 @,拖放/粘贴文件
prompt.placeholder.with.shortcuts=在此输入;使用 / 或 @,拖放/粘贴文件;{0} 发送,{1} 换行
prompt.placeholder.with.send=在此输入;使用 / 或 @,拖放/粘贴文件;{0} 发送
prompt.placeholder.with.newline=在此输入;使用 / 或 @,拖放/粘贴文件;{0} 换行
prompt.button.send=发送
prompt.button.stop=停止
prompt.button.send.tooltip.stop=按 {0} 停止
@@ -1,7 +1,7 @@
session.connection.connecting=載入中…
session.connection.error.app=連線失敗
session.connection.error.workspace=工作區載入失敗
session.connection.error.unknown=未知預設
session.connection.error.unknown=未知錯誤
session.connection.retry=重試
session.connection.warning.config=設定警告
@@ -21,7 +21,7 @@ session.tab.new=新建工作階段
session.tab.untitled=未命名的工作階段
session.permission.title=權限請求
session.permission.meta=工具:{0} • 式:{1}
session.permission.meta=工具:{0} • 式:{1}
session.permission.allow=允許
session.permission.deny=拒絕
session.question.dismiss=關閉
@@ -69,10 +69,10 @@ session.header.context.used=已使用 {0} / {1} 記號
session.header.context.reserved={0} 為輸出預留
session.header.context.available={0} 可用
prompt.placeholder=輸入訊息…
prompt.placeholder.with.shortcuts=輸入訊息…({0} 發送,{1} 換行
prompt.placeholder.with.send=輸入訊息…({0} 發送
prompt.placeholder.with.newline=輸入訊息…({0} 換行
prompt.placeholder=在此輸入;使用 / 或 @,拖放/貼上檔案
prompt.placeholder.with.shortcuts=在此輸入;使用 / 或 @,拖放/貼上檔案;{0} 發送,{1} 換行
prompt.placeholder.with.send=在此輸入;使用 / 或 @,拖放/貼上檔案;{0} 發送
prompt.placeholder.with.newline=在此輸入;使用 / 或 @,拖放/貼上檔案;{0} 換行
prompt.button.send=發送
prompt.button.stop=停止
prompt.button.send.tooltip.stop=按 {0} 停止
@@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@@ -67,4 +68,22 @@ class KiloWorkspaceServiceTest : BasePlatformTestCase() {
assertFalse(ok)
assertEquals(listOf("/test/.kilo/plans/a.md"), rpc.opened)
}
fun `test searchFiles rethrows cancellation`() = runBlocking {
val err = CancellationException("stale completion")
rpc.search = { throw err }
val seen = try {
withContext(Dispatchers.Default) {
service.searchFiles("/test", "dep")
}
fail("expected cancellation")
null
} catch (e: CancellationException) {
e
}
assertEquals(err.message, seen?.message)
assertEquals(listOf("dep"), rpc.searchQueries)
}
}
@@ -152,7 +152,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
val second = active(manager)
assertSame(first, second)
assertEquals(listOf("/test" to "ses_1", "/test" to null), created)
assertTrue(created.containsAll(listOf("/test" to "ses_1", "/test" to null)))
}
fun `test prompted blank session is reused from recents`() {
@@ -382,6 +382,20 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertSame(first, active(manager))
}
fun `test history back restores latest session focus`() {
useLongInactiveDisposeTimeout()
val requests = mutableListOf<JComponent>()
val manager = manager(request = { requests.add(it) })
manager.openSession(session("ses_1"))
val first = active(manager) as SessionUi
manager.showHistory()
requests.clear()
back(manager)
assertSame(first.defaultFocusedComponent, requests.single())
}
fun `test history back without latest session opens new session`() {
val manager = manager()
@@ -416,7 +430,25 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
open(SessionRef.Local(session("ses_1")))
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to "ses_1"), created)
assertTrue(created.contains("/test" to "ses_1"))
}
fun `test opening local history item restores session focus`() {
lateinit var open: (SessionRef) -> Unit
val requests = mutableListOf<JComponent>()
val manager = manager(
history = { _, fn, _ ->
open = fn
JLabel("History")
},
request = { requests.add(it) },
)
manager.showHistory()
open(SessionRef.Local(session("ses_1")))
val active = active(manager) as SessionUi
assertSame(active.defaultFocusedComponent, requests.single())
}
fun `test opening cloud history item shows session ui before import`() {
@@ -625,6 +657,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
private fun manager(
history: ((com.intellij.openapi.Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
request: (JComponent) -> Unit = {},
): SessionSidePanelManager {
val manager = SessionSidePanelManager(
project = project,
@@ -656,6 +689,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
status = { sessions.activity() },
history = history,
timers = timers,
request = request,
)
managers.add(manager)
return manager
@@ -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"])
}
}
@@ -43,6 +43,34 @@ class SessionCreationTest : SessionControllerTestBase() {
assertEquals("ses_test", rpc.prompts[1].first)
}
fun `test same-turn first prompts share session creation`() {
val m = controller()
edt {
m.prompt("first")
m.prompt("second")
}
flush()
assertEquals(1, rpc.creates)
assertEquals(listOf("ses_test", "ses_test"), rpc.prompts.map { it.first })
assertEquals(listOf("first", "second"), rpc.prompts.map { it.third.parts.single().text.toString() }.sorted())
}
fun `test same-turn first prompt and command share session creation`() {
val m = controller()
edt {
m.prompt("first")
m.command("deploy", "prod")
}
flush()
assertEquals(1, rpc.creates)
assertEquals("ses_test", rpc.prompts.single().first)
assertEquals("ses_test", rpc.commands.single().id)
}
fun `test prompt with existing ID skips creation`() {
val m = controller("existing")
collect(m)
@@ -9,6 +9,8 @@ import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionTimeDto
@@ -154,6 +156,88 @@ class SessionModelTest : BasePlatformTestCase() {
assertEquals("ContentRemoved m1/p1", events.single().toString())
}
fun `test updateContent skips user synthetic text`() {
model.addMessage(msg("m1", "user"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = "raw content", synthetic = true))
assertNull(model.message("m1")!!.parts["p1"])
assertTrue(events.isEmpty())
}
fun `test updateContent removes existing user text when marked synthetic`() {
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "visible"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true))
assertNull(model.message("m1")!!.parts["p1"])
assertEquals("ContentRemoved m1/p1", events.single().toString())
}
fun `test assistant synthetic text remains visible`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = "visible", synthetic = true))
assertEquals("visible", (model.message("m1")!!.parts["p1"] as Text).content.toString())
assertEquals("ContentAdded m1/p1", events.single().toString())
}
fun `test updateContent non synthetic text unhides previous synthetic user text`() {
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = "visible now"))
assertModel("""
user#m1
text#p1:
visible now
""")
assertEquals("ContentAdded m1/p1", events.single().toString())
}
fun `test removeMessage clears hiddenText so a later non-synthetic part renders`() {
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true))
model.removeMessage("m1")
events.clear()
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "visible now"))
assertModel("""
user#m1
text#p1:
visible now
""")
assertEquals("ContentAdded m1/p1", events.last().toString())
}
fun `test clear resets hiddenText so a later non-synthetic part renders`() {
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true))
model.clear()
events.clear()
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "visible now"))
assertModel("""
user#m1
text#p1:
visible now
""")
assertEquals("ContentAdded m1/p1", events.last().toString())
}
fun `test updateContent reasoning creates Reasoning content`() {
model.addMessage(msg("m1", "assistant"))
@@ -387,6 +471,17 @@ class SessionModelTest : BasePlatformTestCase() {
assertTrue(events[1] is SessionModelEvent.ContentDelta)
}
fun `test appendDelta ignores hidden synthetic user text part`() {
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "hidden", synthetic = true))
events.clear()
model.appendDelta("m1", "p1", "still hidden")
assertNull(model.message("m1")!!.parts["p1"])
assertTrue(events.isEmpty())
}
fun `test appendDelta on tool content is noop`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "running"))
@@ -579,6 +674,32 @@ class SessionModelTest : BasePlatformTestCase() {
assertTrue(entry.parts["p3"] is StepFinish)
}
fun `test loadHistory skips user synthetic text`() {
model.loadHistory(listOf(MessageWithPartsDto(
msg("m1", "user"),
listOf(
part("p1", "m1", "text", text = "visible"),
part("p2", "m1", "text", text = "hidden", synthetic = true),
),
)))
assertModel("""
user#m1
text#p1:
visible
""")
}
fun `test file attachment preserves source metadata`() {
val source = PartSourceDto("file", PartSourceTextDto("@src/a.kt", 0.0, 9.0), path = "src/a.kt")
model.addMessage(msg("m1", "user"))
model.updateContent("m1", filePart("f1", "m1", "text/plain", "file:///tmp/a.kt", "a.kt", source))
val file = model.message("m1")!!.parts["f1"] as FileAttachment
assertEquals(source, file.source)
}
fun `test updateContent drops patch parts`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
@@ -897,6 +1018,8 @@ class SessionModelTest : BasePlatformTestCase() {
mime: String? = null,
url: String? = null,
filename: String? = null,
synthetic: Boolean? = null,
source: PartSourceDto? = null,
) = PartDto(
id = id,
sessionID = "ses",
@@ -906,6 +1029,8 @@ class SessionModelTest : BasePlatformTestCase() {
mime = mime,
url = url,
filename = filename,
synthetic = synthetic,
source = source,
tool = tool,
state = state,
title = title,
@@ -921,13 +1046,14 @@ class SessionModelTest : BasePlatformTestCase() {
tokens = tokens,
)
private fun filePart(id: String, mid: String, mime: String, url: String, filename: String) = part(
private fun filePart(id: String, mid: String, mime: String, url: String, filename: String, source: PartSourceDto? = null) = part(
id = id,
mid = mid,
type = "file",
mime = mime,
url = url,
filename = filename,
source = source,
)
private fun question(id: String) = Question(
@@ -1,29 +1,56 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.PromptAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider
import ai.kilocode.client.session.ui.prompt.MentionAction
import ai.kilocode.client.session.ui.prompt.PROMPT_ATTACHMENT_PASTE_HANDLER_KEY
import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler
import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.prompt.SlashAction
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.test.CopyProviderSink
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.ide.actions.UndoRedoAction
import com.intellij.icons.AllIcons
import com.intellij.codeInsight.lookup.Lookup
import com.intellij.codeInsight.lookup.LookupManager
import com.intellij.codeInsight.lookup.LookupPositionStrategy
import com.intellij.codeInsight.lookup.impl.LookupImpl
import com.intellij.notification.Notification
import com.intellij.notification.Notifications
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUiKind
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.command.undo.UndoManager
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.actions.PasteAction
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.testFramework.PlatformTestUtil
@@ -35,7 +62,11 @@ import com.intellij.util.Producer
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.awt.Container
import java.awt.BorderLayout
import java.awt.datatransfer.DataFlavor
@@ -56,11 +87,22 @@ import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
class PromptPanelTest : BasePlatformTestCase() {
private val roots = mutableListOf<SessionRootPanel>()
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeWorkspaceRpcApi
private lateinit var workspaces: KiloWorkspaceService
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
rpc = FakeWorkspaceRpcApi()
workspaces = KiloWorkspaceService(scope, rpc)
}
override fun tearDown() {
try {
roots.asReversed().forEach { it.removeNotify() }
roots.clear()
scope.cancel()
} finally {
super.tearDown()
}
@@ -200,6 +242,224 @@ class PromptPanelTest : BasePlatformTestCase() {
assertFalse(editor.settings.isPaintSoftWraps)
}
fun `test prompt editor highlights validated commands and mentions`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
rpc.fileResolver = { emptyList() }
realize(panel, 260, 400)
field.text = "/new use ${MentionAction.GIT_CHANGES.token} and @unknown "
field.getEditor(false)!!.caretModel.moveToOffset(field.text.length)
panel.refreshHighlights()
waitForSend { spans(field).any { it.first == "@unknown" } }
val spans = spans(field)
assertTrue(spans.contains("/new" to DefaultLanguageHighlighterColors.KEYWORD))
assertTrue(spans.contains(MentionAction.GIT_CHANGES.token to DefaultLanguageHighlighterColors.METADATA))
assertTrue(spans.contains("@unknown" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES))
}
fun `test prompt editor exposes file editor for undo redo`() {
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) {
editor.document.insertString(0, "hello")
}
val sink = TestSink()
(field as UiDataProvider).uiDataSnapshot(sink)
val file = sink.file as? TextEditor ?: error("missing file editor")
assertNotNull(file)
assertSame(editor.document, file.editor.document)
UndoManager.getInstance(project).undo(file)
assertEquals("", editor.document.text)
UndoManager.getInstance(project).redo(file)
assertEquals("hello", editor.document.text)
}
fun `test prompt editor platform undo redo actions target prompt editor`() {
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) {
editor.document.insertString(0, "hello")
}
assertSame(true, editor.contentComponent.getClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER))
val sink = TestSink()
(field as UiDataProvider).uiDataSnapshot(sink)
val file = sink.file as? TextEditor ?: error("missing file editor")
assertSame(editor.document, file.editor.document)
assertTrue("prompt file editor should have undo", UndoManager.getInstance(project).isUndoAvailable(file))
invokeAction(IdeActions.ACTION_UNDO, editor.contentComponent, file)
assertEquals("", editor.document.text)
invokeAction(IdeActions.ACTION_REDO, editor.contentComponent, file)
assertEquals("hello", editor.document.text)
}
fun `test prompt editor component undo redo shortcuts target prompt editor`() {
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) {
editor.document.insertString(0, "hello")
}
invokeComponentAction("Kilo Session Undo", editor)
assertEquals("", editor.document.text)
invokeComponentAction("Kilo Session Redo", editor)
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
rpc.fileResolver = { emptyList() }
realize(panel, 260, 400)
field.text = "@missing "
field.getEditor(false)!!.caretModel.moveToOffset(field.text.length)
panel.refreshHighlights()
waitForSend { spans(field).contains("@missing" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES) }
assertTrue(spans(field).contains("@missing" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES))
}
fun `test accepted file mention highlights immediately`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts")))
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
waitForLookupItems(editor)
acceptLookup(editor)
waitForSend { spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA) }
assertTrue(spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA))
}
fun `test invalid edited file mention highlights after caret leaves token`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts")))
rpc.fileResolver = { path -> if (path == "src/deploy.ts") listOf(file(path)) else emptyList() }
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
waitForLookupItems(editor)
acceptLookup(editor)
waitForSend { spans(field).contains("@src/deploy.ts" to DefaultLanguageHighlighterColors.METADATA) }
val offset = field.text.indexOf(' ')
WriteCommandAction.runWriteCommandAction(project) {
editor.document.insertString(offset, "x")
}
editor.caretModel.moveToOffset(offset + 1)
UIUtil.dispatchAllInvocationEvents()
editor.caretModel.moveToOffset(field.text.length)
waitForSend { spans(field).contains("@src/deploy.tsx" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES) }
assertTrue(spans(field).contains("@src/deploy.tsx" to CodeInsightColors.WRONG_REFERENCES_ATTRIBUTES))
}
fun `test prompt clear removes prompt highlighters`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "use ${MentionAction.GIT_CHANGES.token}"
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, field.getEditor(false)!!.markupModel.allHighlighters.size)
panel.clear()
UIUtil.dispatchAllInvocationEvents()
assertEquals(0, field.getEditor(false)!!.markupModel.allHighlighters.size)
}
fun `test prompt highlighters stay bounded across edits`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
repeat(50) {
field.text = if (it % 2 == 0) {
"/new ${MentionAction.GIT_CHANGES.token}"
} else {
"/new ${MentionAction.GIT_CHANGES.token} now"
}
UIUtil.dispatchAllInvocationEvents()
assertTrue(field.getEditor(false)!!.markupModel.allHighlighters.size <= 2)
}
}
fun `test prompt local completion shortcut opens mention lookup`() {
rpc.searchResult = ai.kilocode.rpc.dto.FileSearchResultDto(
files = listOf(ai.kilocode.rpc.dto.WorkspaceFileDto("src/deploy.ts", "deploy.ts")),
)
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
val items = waitForLookupItems(editor)
assertTrue("items=$items", items.contains("src/deploy.ts"))
}
fun `test prompt local completion shortcut opens slash lookup`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "/ne"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
val items = waitForLookupItems(editor)
assertTrue("items=$items", items.contains("new"))
}
fun `test prompt completion lookup is positioned above caret`() {
rpc.searchResult = FileSearchResultDto(
files = listOf(WorkspaceFileDto("src/deploy.ts", "deploy.ts")),
)
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }, completion = completion())
val field = panel.defaultFocusedComponent as EditorTextField
realize(panel, 260, 400)
field.text = "@dep"
val editor = field.getEditor(false)!!
editor.caretModel.moveToOffset(field.text.length)
invokeCompletionAction(editor)
waitForLookupItems(editor)
val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup")
assertEquals(LookupPositionStrategy.ONLY_ABOVE, lookup.presentation.positionStrategy)
}
fun `test prompt editor shrinks when lines are removed`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val editor = panel.defaultFocusedComponent as EditorTextField
@@ -285,6 +545,31 @@ class PromptPanelTest : BasePlatformTestCase() {
assertTrue(sent)
}
fun `test submit resolves mentions from current text`() {
var text: String? = null
var sent: List<PromptPartDto>? = null
val part = PromptPartDto(type = "file", mime = "text/plain", url = "file:///repo/src/x.kt")
val panel = PromptPanel(
project = project,
onSend = { _, files -> sent = files },
onAbort = {},
onEnhance = { _, _ -> },
onMentions = { value ->
text = value
listOf(part)
},
)
val editor = panel.defaultFocusedComponent as EditorTextField
panel.setReady(true)
editor.text = "read @src/x.kt"
panel.send()
waitForSend { sent != null }
assertEquals("read @src/x.kt", text)
assertEquals(listOf(part), sent)
}
fun `test clear removes attachments`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
@@ -778,6 +1063,97 @@ class PromptPanelTest : BasePlatformTestCase() {
return root
}
private fun completion() = KiloPromptCompletionProvider(
workspace = workspaces.workspace("/test"),
service = workspaces,
actions = listOf(
SlashAction(SlashAction.NEW.name, "New") {},
SlashAction("next", "Next") {},
),
mentions = listOf(MentionAction(
MentionAction.GIT_CHANGES.name,
"Git Changes",
available = MentionAction.GIT_CHANGES.available,
)),
scope = scope,
)
private fun invokeCompletionAction(editor: Editor) {
val action = ActionUtil.getActions(editor.contentComponent).first { item ->
item.templatePresentation.text == "Kilo Prompt Completion"
}
val event = event(action, editor)
ActionUtil.updateAction(action, event)
ActionUtil.performAction(action, event)
}
private fun invokeComponentAction(text: String, editor: Editor) {
val action = ActionUtil.getActions(editor.contentComponent).first { item ->
item.templatePresentation.text == text
}
val event = event(action, editor)
ActionUtil.updateAction(action, event)
assertTrue("action $text should be enabled", event.presentation.isEnabled)
ActionUtil.performAction(action, event)
UIUtil.dispatchAllInvocationEvents()
}
private fun invokeAction(id: String, component: java.awt.Component, file: TextEditor) {
val action = ActionManager.getInstance().getAction(id) ?: error("missing action $id")
val ctx = DataContext { data ->
when (data) {
CommonDataKeys.PROJECT.name -> project
PlatformCoreDataKeys.CONTEXT_COMPONENT.name -> component
PlatformCoreDataKeys.FILE_EDITOR.name -> file
else -> null
}
}
val event = AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null)
ActionUtil.updateAction(action, event)
assertTrue("action $id should be enabled", event.presentation.isEnabled)
ActionUtil.performAction(action, event)
UIUtil.dispatchAllInvocationEvents()
}
private fun waitForLookupItems(editor: Editor): List<String> {
repeat(50) {
UIUtil.dispatchAllInvocationEvents()
val items = LookupManager.getActiveLookup(editor)?.items.orEmpty().map { item -> item.lookupString }
if (items.isNotEmpty()) return items
Thread.sleep(20)
}
return LookupManager.getActiveLookup(editor)?.items.orEmpty().map { it.lookupString }
}
private fun acceptLookup(editor: Editor) {
val lookup = LookupManager.getActiveLookup(editor) as? LookupImpl ?: error("missing lookup")
lookup.finishLookup(Lookup.NORMAL_SELECT_CHAR)
UIUtil.dispatchAllInvocationEvents()
}
private fun file(path: String) = WorkspaceFileDto(
path = path,
name = path.substringAfterLast('/'),
)
private fun event(action: AnAction, editor: Editor): AnActionEvent {
val ctx = DataContext { id ->
when (id) {
CommonDataKeys.EDITOR.name -> editor
CommonDataKeys.PROJECT.name -> project
else -> null
}
}
return AnActionEvent.createEvent(action, ctx, null, ActionPlaces.UNKNOWN, ActionUiKind.NONE, null)
}
private fun spans(field: EditorTextField): List<Pair<String, com.intellij.openapi.editor.colors.TextAttributesKey?>> {
val editor = field.getEditor(false)!!
return editor.markupModel.allHighlighters.map {
field.text.substring(it.startOffset, it.endOffset) to it.textAttributesKey
}
}
private fun createEditor(): Editor {
val factory = EditorFactory.getInstance()
return factory.createEditor(factory.createDocument(""), project)
@@ -843,10 +1219,12 @@ class PromptPanelTest : BasePlatformTestCase() {
private class TestSink : CopyProviderSink() {
var send: Any? = null
var file: Any? = null
override fun <T : Any> set(key: DataKey<T>, data: T?) {
super.set(key, data)
if (key == PromptDataKeys.SEND) send = data
if (key == PlatformCoreDataKeys.FILE_EDITOR) file = data
}
}
@@ -15,6 +15,8 @@ import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -264,6 +266,100 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened)
}
fun `test user file mention hides synthetic read payload and text attachment card`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "read @src/a.kt"))
model.updateContent("u1", PartDto(
id = "p2",
sessionID = "ses",
messageID = "u1",
type = "text",
text = "<path>/tmp/a.kt</path>\n<content>raw</content>",
synthetic = true,
))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "file:///tmp/a.kt",
filename = "a.kt",
source = source("src/a.kt"),
))
val msg = panel.findMessage("u1")!!
assertEquals(listOf("p1"), msg.partIds())
assertNull(msg.part("p2"))
assertNull(msg.part("f1"))
assertEquals("read [@src/a.kt](src/a.kt)", (msg.part("p1") as TextView).markdown())
assertEquals(0, msg.components.filterIsInstance<PromptAttachmentView>().size)
}
fun `test user git changes mention hides synthetic data attachment card`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "review @git-changes"))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "data:text/plain;charset=utf-8,diff%20content",
filename = "git-changes.txt",
source = PartSourceDto(
type = "file",
text = PartSourceTextDto("@git-changes", 7.0, 19.0),
path = "git-changes",
),
))
val msg = panel.findMessage("u1")!!
assertEquals(listOf("p1"), msg.partIds())
assertNull(msg.part("f1"))
assertEquals("review [@git-changes](git-changes)", (msg.part("p1") as TextView).markdown())
assertEquals(0, msg.components.filterIsInstance<PromptAttachmentView>().size)
}
fun `test source less text attachment still renders in prompt strip`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "data:text/plain;base64,aGVsbG8=",
filename = "note.txt",
))
val view = panel.findMessage("u1")!!.part("f1")
assertTrue(view is PromptAttachmentView)
assertNotNull(find(view!!, AttachmentCard::class.java))
}
fun `test source backed image attachment still renders in prompt strip`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "image/png",
url = "file:///tmp/a.png",
filename = "a.png",
source = source("src/a.png"),
))
val view = panel.findMessage("u1")!!.part("f1")
assertTrue(view is PromptAttachmentView)
assertNotNull(find(view!!, AttachmentCard::class.java))
}
fun `test empty sanitized user text does not create prompt panel`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "read these screenshots"))
@@ -461,6 +557,12 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, state = state,
)
private fun source(path: String) = PartSourceDto(
type = "file",
path = path,
text = PartSourceTextDto("@$path", 5.0, (6 + path.length).toDouble()),
)
private fun <T : Any> find(root: java.awt.Component, type: Class<T>): T? {
if (type.isInstance(root)) return type.cast(root)
if (root is Container) {
@@ -0,0 +1,390 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.codeInsight.lookup.LookupElementPresentation
import com.intellij.icons.AllIcons
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.textCompletion.TextCompletionUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@Suppress("UnstableApiUsage")
class KiloPromptCompletionProviderTest : BasePlatformTestCase() {
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeWorkspaceRpcApi
private lateinit var provider: KiloPromptCompletionProvider
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
rpc = FakeWorkspaceRpcApi()
val workspaces = KiloWorkspaceService(scope, rpc)
provider = KiloPromptCompletionProvider(
workspace = workspaces.workspace("/test"),
service = workspaces,
actions = listOf(
SlashAction(SlashAction.NEW.name, "New", listOf("clear")) {},
SlashAction("sessions", "Sessions", listOf("continue")) {},
SlashAction("next", "Next") {},
),
mentions = listOf(MentionAction(
MentionAction.GIT_CHANGES.name,
"Git Changes",
available = MentionAction.GIT_CHANGES.available,
)),
scope = scope,
)
}
override fun tearDown() {
try {
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test mention completion shows backend fuzzy results without local filtering`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/foo/Bar.kt")))
complete("@sfb<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "src/foo/Bar.kt")
assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches()))
assertEquals(listOf("sfb"), rpc.searchQueries)
}
fun `test mention completion opens in middle of token`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/deploy.ts")))
complete("@dep<caret>loy")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "src/deploy.ts")
assertEquals(listOf("dep"), rpc.searchQueries)
}
fun `test slash completion opens in middle of token`() {
complete("/ne<caret>w")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new")
assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches()))
}
fun `test mention completion reuses identical prefix result`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt")))
complete("@main<caret>")
complete("@main<caret>")
assertEquals(listOf("main"), rpc.searchQueries)
}
fun `test clearing mentions resets cached prefix result`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("src/Main.kt")))
complete("@main<caret>")
provider.clearMentions()
complete("@main<caret>")
assertEquals(listOf("main", "main"), rpc.searchQueries)
}
fun `test mention completion includes matching special items`() {
rpc.searchResult = FileSearchResultDto(git = true)
complete("@git<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), MentionAction.GIT_CHANGES.name)
assertEquals(listOf("git"), rpc.searchQueries)
}
fun `test mention completion keeps no-match placeholder`() {
rpc.searchResult = FileSearchResultDto()
complete("@zzz<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), noMatches())
assertFalse(myFixture.lookupElementStrings.orEmpty().contains("src/foo/Bar.kt"))
assertEquals(listOf("zzz"), rpc.searchQueries)
}
fun `test accepting mention no-match placeholder preserves prefix`() {
rpc.searchResult = FileSearchResultDto()
complete("@zzz<caret>")
myFixture.type('\n')
assertEquals("@zzz", myFixture.editor.document.text)
assertNull(provider.mentionAt("@zzz", 1))
}
fun `test accepting mention mid token replaces glued suffix`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("backend/deploy-dev.sh")))
complete("@backend/deploy<caret>-dev.sh")
myFixture.type('\n')
assertEquals("@backend/deploy-dev.sh ", myFixture.editor.document.text)
assertEquals(
KiloPromptCompletionProvider.MentionHit(0, 22, "backend/deploy-dev.sh", true),
provider.mentionAt(myFixture.editor.document.text, 1),
)
}
fun `test accepting mention mid token trims before trailing content`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("backend/deploy-dev.sh")))
complete("@backend/deploy<caret>-dev.sh tail")
myFixture.type('\n')
assertEquals("@backend/deploy-dev.sh tail", myFixture.editor.document.text)
assertEquals(
KiloPromptCompletionProvider.MentionHit(0, 22, "backend/deploy-dev.sh", true),
provider.mentionAt(myFixture.editor.document.text, 1),
)
}
fun `test slash completion keeps no-match placeholder`() {
complete("/zzz<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), noMatches())
}
fun `test slash completion hides placeholder for real matches`() {
complete("/ne<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new")
assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches()))
}
fun `test slash completion matches client aliases`() {
complete("/cle<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), "new")
assertFalse(myFixture.lookupElementStrings.orEmpty().contains(noMatches()))
}
fun `test blank mention completion includes special and root entries`() {
rpc.searchResult = FileSearchResultDto(
files = listOf(file("src", directory = true), file("README.md")),
git = true,
)
complete("@<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), MentionAction.GIT_CHANGES.name, "src", "README.md")
val items = myFixture.lookupElementStrings.orEmpty()
assertEquals(MentionAction.GIT_CHANGES.name, items.first())
assertEquals(listOf(""), rpc.searchQueries)
}
fun `test prewarm serves blank mention completion from cache`() {
rpc.searchResult = FileSearchResultDto(
files = listOf(file("src", directory = true), file("README.md")),
git = true,
)
provider.prewarm()
waitFor { rpc.searchQueries.contains("") }
complete("@<caret>")
assertContainsElements(myFixture.lookupElementStrings.orEmpty(), MentionAction.GIT_CHANGES.name, "src", "README.md")
assertEquals(listOf(""), rpc.searchQueries)
}
fun `test mention completion renders file type icons`() {
rpc.searchResult = FileSearchResultDto(files = listOf(file("image.png"), file("src", directory = true)))
complete("@<caret>")
assertEquals(FileTypeManager.getInstance().getFileTypeByFileName("image.png").icon ?: AllIcons.FileTypes.Text, icon("image.png"))
assertSame(AllIcons.Nodes.Folder, icon("src"))
}
fun `test highlights known slash command at start`() {
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(0, 4, KiloPromptCompletionProvider.HighlightKind.COMMAND)),
provider.highlights("/new start fresh"),
)
}
fun `test highlights client slash command aliases at start`() {
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(0, 6, KiloPromptCompletionProvider.HighlightKind.COMMAND)),
provider.highlights("/clear"),
)
}
fun `test highlights server slash command at start`() {
rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("deploy")))
waitFor { provider.highlights("/deploy prod").isNotEmpty() }
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(0, 7, KiloPromptCompletionProvider.HighlightKind.COMMAND)),
provider.highlights("/deploy prod"),
)
}
fun `test highlights ignore unknown and non-leading slash commands`() {
assertTrue(provider.highlights("/bogus now").isEmpty())
assertTrue(provider.highlights("hi /new").isEmpty())
}
fun `test highlights special mentions without tracked paths`() {
assertEquals(
listOf(
KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.MENTION),
),
provider.highlights("use ${MentionAction.GIT_CHANGES.token}").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 clientAction resolves canonical names and aliases`() {
assertEquals(SlashAction.NEW.name, provider.clientAction("/new")?.name)
assertEquals(SlashAction.NEW.name, provider.clientAction("/clear")?.name)
assertEquals("sessions", provider.clientAction("/continue")?.name)
assertNull(provider.clientAction("hi /clear"))
}
fun `test serverCommand does not route client aliases`() {
rpc.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY, commands = listOf(CommandDto("clear")))
assertNull(provider.serverCommand("/clear"))
}
fun `test highlights tracked mentions longest first`() {
addMention("src/a.ts", "@ts")
addMention("src/a.tsx", "@tsx")
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(4, 14, KiloPromptCompletionProvider.HighlightKind.MENTION)),
provider.highlights("see @src/a.tsx"),
)
}
fun `test highlights unknown mentions are pending before validation`() {
assertTrue(provider.highlights("see @unknownPath").isEmpty())
}
fun `test highlights unresolved mention after validation`() {
var done = false
rpc.fileResolver = { emptyList() }
provider.validate("see @unknownPath", -1) { done = true }
waitFor { done }
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(4, 16, KiloPromptCompletionProvider.HighlightKind.INVALID)),
provider.highlights("see @unknownPath", -1),
)
}
fun `test highlights existing hand typed mention after validation`() {
var done = false
rpc.fileResolver = { path -> if (path == "src/x.kt") listOf(file(path)) else emptyList() }
provider.validate("see @src/x.kt", -1) { done = true }
waitFor { done }
assertEquals(
listOf(KiloPromptCompletionProvider.Highlight(4, 13, KiloPromptCompletionProvider.HighlightKind.MENTION)),
provider.highlights("see @src/x.kt", -1),
)
}
fun `test mention under caret is not flagged`() {
assertTrue(provider.highlights("@nope", caret = 5).isEmpty())
}
fun `test mentionAt resolves tracked file mention`() {
addMention("src/a.ts", "@ts")
assertEquals(
KiloPromptCompletionProvider.MentionHit(4, 13, "src/a.ts", true),
provider.mentionAt("see @src/a.ts", 6),
)
}
fun `test mentionAt marks unresolved file mention after validation`() {
var done = false
rpc.fileResolver = { emptyList() }
provider.validate("see @missing.ts", -1) { done = true }
waitFor { done }
assertEquals(
KiloPromptCompletionProvider.MentionHit(4, 15, "missing.ts", false),
provider.mentionAt("see @missing.ts", 6),
)
}
fun `test mentionAt ignores special pending and out of range`() {
assertNull(provider.mentionAt("use ${MentionAction.GIT_CHANGES.token}", 6))
assertNull(provider.mentionAt("see @unvalidated.ts", 6))
assertNull(provider.mentionAt("hello world", 3))
}
fun `test navigate opens resolved mention file`() {
rpc.fileResolver = { path -> if (path == "src/a.ts") listOf(file(path)) else emptyList() }
provider.navigate("src/a.ts")
waitFor { rpc.opened.contains("src/a.ts") }
assertTrue(rpc.opened.contains("src/a.ts"))
}
private fun complete(text: String) {
val file = myFixture.configureByText("prompt.txt", text)
TextCompletionUtil.installProvider(file, provider, true)
myFixture.completeBasic()
}
private fun addMention(path: String, query: String) {
rpc.searchResult = FileSearchResultDto(files = listOf(file(path)))
complete("$query<caret>")
myFixture.type('\n')
assertEquals(path, provider.mentionAt("@$path", 1)?.value)
}
private fun waitFor(done: () -> Boolean) {
repeat(50) {
com.intellij.util.ui.UIUtil.dispatchAllInvocationEvents()
if (done()) return
Thread.sleep(20)
}
}
private fun icon(value: String) = LookupElementPresentation().also { item(value).renderElement(it) }.icon
private fun item(value: String) = myFixture.lookupElements.orEmpty().first { it.lookupString == value }
private fun file(path: String, directory: Boolean = false) = WorkspaceFileDto(
path = path,
name = path.substringAfterLast('/'),
directory = directory,
)
private fun noMatches() = KiloBundle.message("prompt.completion.noMatches")
}
@@ -0,0 +1,119 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.WorkspaceFileDto
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.event.EditorMouseEvent
import com.intellij.openapi.editor.event.EditorMouseEventArea
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.awt.event.InputEvent
import java.awt.event.MouseEvent
@Suppress("UnstableApiUsage")
class MentionNavigatorTest : BasePlatformTestCase() {
// "see @src/a.ts and @missing.ts" — resolved token spans 4..13, unresolved spans 18..29.
private val text = "see @src/a.ts and @missing.ts"
private lateinit var scope: CoroutineScope
private lateinit var rpc: FakeWorkspaceRpcApi
private lateinit var provider: KiloPromptCompletionProvider
private lateinit var editor: EditorEx
private lateinit var navigator: MentionNavigator
override fun setUp() {
super.setUp()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
rpc = FakeWorkspaceRpcApi()
rpc.fileResolver = { path -> if (path == "src/a.ts") listOf(file(path)) else emptyList() }
val workspaces = KiloWorkspaceService(scope, rpc)
provider = KiloPromptCompletionProvider(
workspace = workspaces.workspace("/test"),
service = workspaces,
actions = emptyList(),
mentions = emptyList(),
scope = scope,
)
val factory = EditorFactory.getInstance()
editor = factory.createEditor(factory.createDocument(text), project) as EditorEx
navigator = MentionNavigator(editor, provider)
navigator.install()
provider.validate(text, -1) {}
waitFor {
provider.mentionAt(text, 6)?.resolved == true && provider.mentionAt(text, 20)?.resolved == false
}
}
override fun tearDown() {
try {
EditorFactory.getInstance().releaseEditor(editor)
scope.cancel()
} finally {
super.tearDown()
}
}
fun `test hovering unresolved mention sets tooltip`() {
navigator.mouseMoved(event(20))
assertEquals(KiloBundle.message("prompt.mention.unresolved", "missing.ts"), editor.contentComponent.toolTipText)
}
fun `test leaving mention clears tooltip`() {
navigator.mouseMoved(event(20))
navigator.mouseMoved(event(1))
assertNull(editor.contentComponent.toolTipText)
}
fun `test modifier hover highlights resolved mention as link`() {
navigator.mouseMoved(event(6, modifier = true))
assertTrue(editor.markupModel.allHighlighters.any { it.layer == HighlighterLayer.HYPERLINK })
navigator.mouseMoved(event(6, modifier = false))
assertFalse(editor.markupModel.allHighlighters.any { it.layer == HighlighterLayer.HYPERLINK })
}
fun `test modifier click opens resolved mention`() {
navigator.mouseClicked(event(6, modifier = true))
waitFor { rpc.opened.contains("src/a.ts") }
assertTrue(rpc.opened.contains("src/a.ts"))
}
private fun event(offset: Int, modifier: Boolean = false): EditorMouseEvent {
val mask = if (modifier) InputEvent.META_DOWN_MASK or InputEvent.CTRL_DOWN_MASK else 0
val point = editor.offsetToXY(offset)
val mouse = MouseEvent(editor.contentComponent, MouseEvent.MOUSE_MOVED, 0L, mask, point.x, point.y, 1, false)
return EditorMouseEvent(
editor,
mouse,
EditorMouseEventArea.EDITING_AREA,
offset,
editor.offsetToLogicalPosition(offset),
editor.offsetToVisualPosition(offset),
true,
null,
null,
null,
)
}
private fun file(path: String) = WorkspaceFileDto(path = path, name = path.substringAfterLast('/'))
private fun waitFor(done: () -> Boolean) {
repeat(50) {
UIUtil.dispatchAllInvocationEvents()
if (done()) return
Thread.sleep(20)
}
}
}
@@ -0,0 +1,161 @@
package ai.kilocode.client.session.ui.prompt
import junit.framework.TestCase
import kotlinx.coroutines.runBlocking
import java.nio.file.Path
class PromptMentionPartsTest : TestCase() {
fun `test promptMentions parses boundary mentions`() {
assertEquals(
listOf(Mention("src/Main.kt", 5, 17), Mention("README.md", 22, 32)),
promptMentions("read @src/Main.kt and @README.md"),
)
assertEquals(listOf(Mention("src/x.kt", 4, 13)), promptMentions("see @src/x.kt"))
}
fun `test promptMentions ignores embedded and bare markers`() {
assertTrue(promptMentions("read foo@src/Main.kt and @").isEmpty())
}
fun `test promptMentions handles whitespace and punctuation as token text`() {
assertEquals(
listOf(Mention("src/a.kt", 4, 13), Mention("src/b.kt,", 14, 24)),
promptMentions("see\t@src/a.kt\n@src/b.kt,"),
)
}
fun `test fileMentions drops reserved names and deduplicates`() {
assertEquals(
listOf(Mention("src/a.kt", 5, 14), Mention("src/b.kt", 38, 47)),
fileMentions("read @src/a.kt ${MentionAction.GIT_CHANGES.token} @src/a.kt @src/b.kt", setOf(MentionAction.GIT_CHANGES.name)),
)
}
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 ignores edited mention suffix`() {
assertTrue(mentionFileParts("read @src/Main.kt-extra", setOf("src/Main.kt"), "/repo").isEmpty())
assertTrue(mentionFileParts("read @src/Main.kt.bak", setOf("src/Main.kt"), "/repo").isEmpty())
}
fun `test mentionFileParts ignores embedded mention text`() {
assertTrue(mentionFileParts("read foo@src/Main.kt", setOf("src/Main.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 mentionParts validates hand typed mention at send time`() = runBlocking {
val parts = mentionParts(
text = "read @src/Main.kt",
directory = "/repo",
reserved = setOf(MentionAction.GIT_CHANGES.name),
resolve = { path -> path == "src/Main.kt" },
gitChanges = { null },
)
val part = parts.single()
assertEquals(Path.of("/repo/src/Main.kt").toUri().toString(), part.url)
assertEquals("@src/Main.kt", part.source?.text?.value)
assertEquals(5.0, part.source?.text?.start)
}
fun `test mentionParts drops unresolved file mentions`() = runBlocking {
assertTrue(mentionParts(
text = "read @src/Main.kt",
directory = "/repo",
reserved = setOf(MentionAction.GIT_CHANGES.name),
resolve = { false },
gitChanges = { null },
).isEmpty())
}
fun `test mentionParts handles mixed resolved reserved and git changes`() = runBlocking {
val parts = mentionParts(
text = "read @src/Main.kt @missing.kt ${MentionAction.GIT_CHANGES.token}",
directory = "/repo",
reserved = setOf(MentionAction.GIT_CHANGES.name),
resolve = { path -> path == "src/Main.kt" },
gitChanges = { "diff" },
)
assertEquals(2, parts.size)
assertEquals("Main.kt", parts[0].filename)
assertEquals(MentionAction.GIT_CHANGES.filename, parts[1].filename)
assertEquals(MentionAction.GIT_CHANGES.uri, parts[1].source?.path)
}
fun `test mentionParts skips blank git changes`() = runBlocking {
assertTrue(mentionParts(
text = "review ${MentionAction.GIT_CHANGES.token}",
directory = "/repo",
reserved = setOf(MentionAction.GIT_CHANGES.name),
resolve = { true },
gitChanges = { " " },
).isEmpty())
}
fun `test mentionParts skips git lookup without token`() = runBlocking {
var called = false
mentionParts(
text = "read @src/Main.kt",
directory = "/repo",
reserved = setOf(MentionAction.GIT_CHANGES.name),
resolve = { false },
gitChanges = {
called = true
"diff"
},
)
assertFalse(called)
}
fun `test gitChangesPart builds encoded data part`() {
val part = gitChangesPart("review ${MentionAction.GIT_CHANGES.token}", "hello world+plus")!!
assertEquals("file", part.type)
assertEquals("text/plain", part.mime)
assertEquals(MentionAction.GIT_CHANGES.filename, part.filename)
assertEquals("data:text/plain;charset=utf-8,hello%20world%2Bplus", part.url)
assertEquals("file", part.source?.type)
assertEquals(MentionAction.GIT_CHANGES.uri, part.source?.path)
assertNull(part.source?.clientName)
assertNull(part.source?.uri)
assertEquals(MentionAction.GIT_CHANGES.token, 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 ${MentionAction.GIT_CHANGES.token}", null))
assertNull(gitChangesPart("review ${MentionAction.GIT_CHANGES.token}", " "))
assertNull(gitChangesPart("review ${MentionAction.GIT_CHANGES.token}-foo", "diff"))
assertNull(gitChangesPart("review foo${MentionAction.GIT_CHANGES.token}", "diff"))
}
}
@@ -1,8 +1,15 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.PartSourceDto
import ai.kilocode.rpc.dto.PartSourceTextDto
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.util.ui.JBUI
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -243,11 +250,170 @@ class TextViewTest : BasePlatformTestCase() {
val urls = mutableListOf<String>()
val view = TextView(Text("p1"), openUrl = { urls.add(it) })
view.md.simulateLink("https://kilocode.ai/docs")
view.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
fun `test linkifyMentions rewrites tracked token`() {
val out = linkifyMentions(
"read @src/a.kt",
listOf(PromptMention("@src/a.kt", "src/a.kt", 5, 14)),
)
assertEquals("read [@src/a.kt](src/a.kt)", out)
}
fun `test linkifyMentions escapes text and encodes href`() {
val out = linkifyMentions(
"open @[a] file.kt",
listOf(PromptMention("@[a] file.kt", "[a] file.kt", 5, 17)),
)
assertEquals("open [@\\[a\\] file.kt]([a]%20file.kt)", out)
}
fun `test linkifyMentions handles multiple mentions by offset`() {
val out = linkifyMentions(
"read @src/a.kt and @src/b.kt",
listOf(
PromptMention("@src/a.kt", "src/a.kt", 5, 14),
PromptMention("@src/b.kt", "src/b.kt", 19, 28),
),
)
assertEquals("read [@src/a.kt](src/a.kt) and [@src/b.kt](src/b.kt)", out)
}
fun `test linkifyMentions falls back to literal replacement when offset drifts`() {
val out = linkifyMentions(
"read @src/a.kt",
listOf(PromptMention("@src/a.kt", "src/a.kt", 0, 4)),
)
assertEquals("read [@src/a.kt](src/a.kt)", out)
}
fun `test linkifyMentions fallback does not relink generated markdown`() {
val out = linkifyMentions(
"read @src/a.kt and @src/a.kt",
listOf(
PromptMention("@src/a.kt", "src/a.kt", 5, 14),
PromptMention("@src/a.kt", "src/a.kt", 0, 4),
),
)
assertEquals("read [@src/a.kt](src/a.kt) and [@src/a.kt](src/a.kt)", out)
}
fun `test linkifyMentions leaves text without mentions unchanged`() {
assertEquals("read @src/a.kt", linkifyMentions("read @src/a.kt", emptyList()))
}
fun `test promptMentions extracts source backed text files`() {
val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0)))
msg.parts["keep"] = file("keep", "text/plain", "@src/a.kt", "src/a.kt", 0, 9)
msg.parts["image"] = file("image", "image/png", "@src/a.png", "src/a.png", 0, 10)
msg.parts["blank"] = file("blank", "text/plain", "@src/b.kt", "", 0, 9)
msg.parts["plain"] = FileAttachment("plain").also { it.mime = "text/plain" }
val mentions = promptMentions(msg)
assertEquals(1, mentions.size)
assertEquals("@src/a.kt", mentions.single().token)
assertEquals("src/a.kt", mentions.single().path)
assertEquals(0, mentions.single().start)
assertEquals(9, mentions.single().end)
assertSame(msg.parts["keep"], mentions.single().attachment)
}
fun `test prompt view renders mention as link`() {
val text = Text("p1").also { it.content.append("read @src/a.kt") }
val view = PromptView(text, mentions = listOf(PromptMention("@src/a.kt", "src/a.kt", 5, 14)))
assertEquals("read [@src/a.kt](src/a.kt)", view.markdown())
assertTrue(view.md.html().contains("href=\"src/a.kt\""))
assertTrue(view.md.html().contains("@src/a.kt"))
}
fun `test prompt view routes mention links to file and urls to browser`() {
val files = mutableListOf<String>()
val urls = mutableListOf<String>()
val text = Text("p1").also { it.content.append("read @src/a file.kt") }
val view = PromptView(
text,
openFile = { files.add(it) },
openUrl = { urls.add(it) },
mentions = listOf(PromptMention("@src/a file.kt", "src/a file.kt", 5, 19)),
)
view.simulateLink("src/a%20file.kt")
view.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("src/a file.kt"), files)
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
fun `test prompt view routes attachment backed mention link to attachment opener`() {
val opened = mutableListOf<FileAttachment>()
val item = file("f1", "text/plain", "@git-changes", "git-changes", 7, 19).also {
it.url = "data:text/plain;charset=utf-8,diff%20content"
it.filename = "git-changes.txt"
}
val text = Text("p1").also { it.content.append("review @git-changes") }
val view = PromptView(
text,
openFile = { error("should not open file") },
openAttachment = { opened.add(it) },
mentions = listOf(PromptMention("@git-changes", "git-changes", 7, 19, item)),
)
view.simulateLink("git-changes")
assertEquals(listOf(item), opened)
}
fun `test prompt view setMentions refreshes existing prompt`() {
val text = Text("p1").also { it.content.append("read @src/a.kt") }
val view = PromptView(text)
view.setMentions(listOf(PromptMention("@src/a.kt", "src/a.kt", 5, 14)))
assertEquals("read [@src/a.kt](src/a.kt)", view.markdown())
}
fun `test message view syncs prompt mentions from hidden part`() {
val msg = Message(MessageDto("m1", "ses", "user", MessageTimeDto(0.0)))
val view = MessageView(msg, openFile = {})
val text = Text("p1").also { it.content.append("read @src/a.kt") }
msg.parts["p1"] = text
view.upsertPart(text)
assertEquals("read @src/a.kt", (view.part("p1") as PromptView).markdown())
val mention = file("f1", "text/plain", "@src/a.kt", "src/a.kt", 5, 14)
msg.parts["f1"] = mention
view.upsertPart(mention)
assertNull(view.part("f1"))
assertEquals("read [@src/a.kt](src/a.kt)", (view.part("p1") as PromptView).markdown())
}
fun `test prompt view colors links with metadata color`() {
val style = SessionEditorStyle.current()
val color = style.editorScheme.getAttributes(DefaultLanguageHighlighterColors.METADATA)?.foregroundColor
val view = PromptView(Text("p1"))
val before = view.md.linkColor
view.applyStyle(style)
assertEquals(color ?: before, view.md.linkColor)
}
private fun file(id: String, mime: String, token: String, path: String, start: Int, end: Int) = FileAttachment(id).also {
it.mime = mime
it.source = PartSourceDto("file", PartSourceTextDto(token, start.toDouble(), end.toDouble()), path = path)
}
private class TrackingRepaintManager(private val watched: JComponent) : RepaintManager() {
var dirty = 0
var invalid = 0
@@ -84,7 +84,9 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
var enhanced = "Enhanced prompt"
var enhanceGate: CompletableDeferred<Unit>? = null
var enhanceThrows: Exception? = null
var commandThrows: Exception? = null
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
val commands = mutableListOf<CommandCall>()
val attachmentParts = mutableListOf<AttachmentCall>()
val aborts = mutableListOf<Pair<String, String>>()
val compacts = mutableListOf<Triple<String, String, ModelSelectionDto>>()
@@ -106,6 +108,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
data class CloudCall(val directory: String, val cursor: String?, val limit: Int, val gitUrl: String?)
data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?)
data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto)
// --- Implementation ---
@@ -195,6 +198,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
prompts.add(Triple(id, directory, prompt))
}
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))
}
override suspend fun abort(id: String, directory: String) {
assertNotEdt("abort")
aborts.add(id to directory)
@@ -2,6 +2,7 @@ package ai.kilocode.client.testing
import ai.kilocode.rpc.KiloWorkspaceRpcApi
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
@@ -27,6 +28,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
var models = ModelsWorkspaceDto()
var modelsGate: CompletableDeferred<Unit>? = null
var fileMatches = emptyList<WorkspaceFileDto>()
var fileResolver: ((String) -> List<WorkspaceFileDto>)? = null
var searchResult = FileSearchResultDto()
var search: ((String) -> FileSearchResultDto)? = null
var gitChanges: String? = null
var openResult = true
var localConfigPath = "/test/.kilo/kilo.jsonc"
var globalConfigPath = "/config/kilo.jsonc"
@@ -35,6 +40,7 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
var localConfigExists = true
var globalConfigExists = true
val fileCalls = mutableListOf<Pair<String, String>>()
val searchQueries = mutableListOf<String>()
val opened = mutableListOf<String>()
val localConfigs = mutableListOf<String>()
var globalConfigs = 0
@@ -67,7 +73,18 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi {
override suspend fun files(directory: String, path: String): List<WorkspaceFileDto> {
assertNotEdt("files")
fileCalls.add(directory to path)
return fileMatches
return fileResolver?.invoke(path) ?: fileMatches
}
override suspend fun searchFiles(directory: String, query: String, limit: Int): FileSearchResultDto {
assertNotEdt("searchFiles")
searchQueries.add(query)
return search?.invoke(query) ?: searchResult
}
override suspend fun gitChanges(directory: String): String? {
assertNotEdt("gitChanges")
return gitChanges
}
override suspend fun openFile(path: String): Boolean {
@@ -78,6 +78,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** Send a prompt to a session (fire-and-forget). */
suspend fun prompt(id: String, directory: String, prompt: PromptDto)
/** Run a configured slash command/workflow in a session. */
suspend fun command(id: String, directory: String, command: String, arguments: String, prompt: PromptDto)
/** Abort ongoing processing for a session. */
suspend fun abort(id: String, directory: String)
@@ -1,6 +1,7 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ConfigTargetDto
import ai.kilocode.rpc.dto.FileSearchResultDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.ModelsWorkspaceDto
import ai.kilocode.rpc.dto.WorkspaceFileDto
@@ -46,6 +47,12 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
/** Resolve [path] to matching files, scoped primarily to [directory]. */
suspend fun files(directory: String, path: String): List<WorkspaceFileDto>
/** Fuzzy file/folder search via the backend IDE index. */
suspend fun searchFiles(directory: String, query: String, limit: Int = 50): FileSearchResultDto
/** Current uncommitted git changes as a unified diff for @git-changes mentions. */
suspend fun gitChanges(directory: String): String?
/** Open an absolute backend file path in the IDE. */
suspend fun openFile(path: String): Boolean
@@ -75,6 +75,26 @@ data class PartDto(
val mime: String? = null,
val url: String? = null,
val filename: String? = null,
val synthetic: Boolean? = null,
val source: PartSourceDto? = null,
)
@Serializable
data class PartSourceDto(
val type: String,
val text: PartSourceTextDto,
val path: String? = null,
val clientName: String? = null,
val uri: String? = null,
val name: String? = null,
val kind: Int? = null,
)
@Serializable
data class PartSourceTextDto(
val value: String,
val start: Double,
val end: Double,
)
@Serializable
@@ -103,6 +123,7 @@ data class PromptPartDto(
val mime: String? = null,
val url: String? = null,
val filename: String? = null,
val source: PartSourceDto? = null,
)
// --- Streaming Events ---
@@ -8,3 +8,10 @@ data class WorkspaceFileDto(
val name: String,
val directory: Boolean = false,
)
@Serializable
data class FileSearchResultDto(
val indexing: Boolean = false,
val files: List<WorkspaceFileDto> = emptyList(),
val git: Boolean = false,
)