mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge pull request #11327 from Kilo-Org/basalt-dirt
feat(jetbrains): add slash commands and mention completion
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
+59
@@ -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> {
|
||||
|
||||
+6
@@ -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) }
|
||||
|
||||
|
||||
+174
-11
@@ -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 }
|
||||
}
|
||||
|
||||
+18
@@ -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()
|
||||
|
||||
+137
@@ -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
|
||||
|
||||
+150
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -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
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
|
||||
+6
@@ -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) }
|
||||
|
||||
+22
@@ -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 {
|
||||
|
||||
+11
-7
@@ -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?) {
|
||||
|
||||
+81
@@ -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()
|
||||
|
||||
+81
-29
@@ -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" }
|
||||
|
||||
+2
@@ -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. */
|
||||
|
||||
+25
@@ -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)
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+78
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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()
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
+306
@@ -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 }
|
||||
}
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
@@ -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
-1
@@ -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)
|
||||
|
||||
+89
@@ -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,
|
||||
)
|
||||
+176
-56
@@ -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()
|
||||
|
||||
}
|
||||
|
||||
+36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -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 {
|
||||
|
||||
+71
@@ -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)"
|
||||
}
|
||||
+56
@@ -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"
|
||||
}
|
||||
|
||||
+7
-3
@@ -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()
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+5
-5
@@ -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}
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+4
-4
@@ -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}を押してください
|
||||
|
||||
+8
-8
@@ -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=세션의 이름을 바꾸지 못했습니다
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+4
-4
@@ -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}
|
||||
|
||||
+4
-4
@@ -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
-4
@@ -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} เพื่อหยุด
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-5
@@ -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}
|
||||
|
||||
+4
-4
@@ -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} 停止
|
||||
|
||||
+6
-6
@@ -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} 停止
|
||||
|
||||
+19
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+36
-2
@@ -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
|
||||
|
||||
+69
@@ -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"])
|
||||
}
|
||||
}
|
||||
+28
@@ -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)
|
||||
|
||||
+127
-1
@@ -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(
|
||||
|
||||
+378
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
@@ -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) {
|
||||
|
||||
+390
@@ -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")
|
||||
}
|
||||
+119
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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"))
|
||||
}
|
||||
}
|
||||
+167
-1
@@ -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
|
||||
|
||||
+9
@@ -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)
|
||||
|
||||
+18
-1
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user