mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge branch 'main' into feat/minimax-m3-thinking-toggle
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show a clear, retryable provider rate-limit error instead of raw response JSON in chat.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Add a shared model setting to hide Kilo Gateway models that may train on your prompts across Kilo clients.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Hide generated read-tool payload lines from JetBrains prompt bubbles while keeping attachments and assistant tool output visible.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show JetBrains prompt attachments in one horizontal scrolling row in session history.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support pasting files and images into JetBrains chat prompts as attachments.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show JetBrains prompt attachments inside the prompt bubble with previews and open embedded attachments in editor tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support sending file and image attachments from the JetBrains chat prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support dropping files anywhere in a JetBrains chat session to add them to the prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Open embedded JetBrains message attachments in frontend-managed Kilo editor tabs with loading and connection retry states.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Make native Plan mode follow Architect-style planning behavior while preserving Plan mode restrictions and repo-root plan files.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Offer opt-in attention sounds when sessions finish, error, or need input across the sidebar, editor tabs, and Agent Manager.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Render file attachments as attachment cards in JetBrains prompts and session history.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Open embedded transcript attachments in stable Kilo editor tabs.
|
||||
@@ -302,6 +302,7 @@
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-eDZ7bR8/xjtQj8Y+X02h/D7afn2TGC3xOTnDmwFKkwo=",
|
||||
"aarch64-linux": "sha256-CvsqQ8uSoZmc/9ClO4I/Wwb2NIQqynt57n/G0P9mFPY=",
|
||||
"aarch64-darwin": "sha256-mIC3G1zON6WmKyZT5YaYGc0Wh6v/tQ+aJaZcU7/nyzE=",
|
||||
"x86_64-darwin": "sha256-U6A3ZPFUVUDdgwxCJXX5X0HzR6DiLzIoHViDoQDiMMY="
|
||||
"x86_64-linux": "sha256-jvRriUZ4wVKCs5Lek4b+Ln98WJe3QUZ9xzLX7E6XUG4=",
|
||||
"aarch64-linux": "sha256-tcr6VJXR0H0iCS4LaRPb8tjapNIt8+WVn1jyWWiwUdg=",
|
||||
"aarch64-darwin": "sha256-Rwg8EbHvxNo1mDr2RqPUDAX7QR//pFcnmgXfDbIx4oY=",
|
||||
"x86_64-darwin": "sha256-Wsu1lxKb/gzdi7i0iwok9Uejk9QLEn/K3jaqURjsyT0="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4
|
||||
size 30244
|
||||
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
|
||||
size 37836
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bbfed6606c5e1ccd6a744bdadf34ee597007d5f82c436c8c53e189560e0837d4
|
||||
size 30244
|
||||
oid sha256:bae54b771e2d7da8c8b1dcab42d9d51662ec888ebb618b24af11e597c79df7b7
|
||||
size 37836
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6bcfc6c12f69c5131dcc8f294f480e26ff53f87e0c5943bf525e69c4c894ad21
|
||||
size 43744
|
||||
oid sha256:57fa5c60dd92a2f4299e58ead8a682c4802de415a496c247af2eba2faf0d63e4
|
||||
size 50341
|
||||
|
||||
+31
-9
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -79,6 +80,7 @@ class KiloBackendChatManager(
|
||||
private var client: OkHttpClient? = null
|
||||
private var base: String? = null
|
||||
private var watcher: Job? = null
|
||||
private var normalizer = KiloCliDataParser.ChatEventNormalizer()
|
||||
|
||||
fun start(http: OkHttpClient, port: Int, sse: SharedFlow<SseEvent>) {
|
||||
client = http
|
||||
@@ -87,16 +89,18 @@ class KiloBackendChatManager(
|
||||
watcher = cs.launch {
|
||||
sse.collect { event ->
|
||||
if (event.type in CHAT_EVENTS) {
|
||||
val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data)
|
||||
if (parsed != null) {
|
||||
log.debug { ChatLogSummary.event(parsed) }
|
||||
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
|
||||
log.info(
|
||||
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
|
||||
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
|
||||
)
|
||||
val events = normalizer.parse(event.type, event.data)
|
||||
if (events != null) {
|
||||
for (parsed in events) {
|
||||
log.debug { ChatLogSummary.event(parsed) }
|
||||
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
|
||||
log.info(
|
||||
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
|
||||
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
|
||||
)
|
||||
}
|
||||
_events.emit(parsed)
|
||||
}
|
||||
_events.emit(parsed)
|
||||
} else {
|
||||
log.warn("SSE parse returned null for type=${event.type} bytes=${event.data.length}")
|
||||
}
|
||||
@@ -111,6 +115,7 @@ class KiloBackendChatManager(
|
||||
watcher = null
|
||||
client = null
|
||||
base = null
|
||||
normalizer = KiloCliDataParser.ChatEventNormalizer()
|
||||
log.info("Chat manager stopped")
|
||||
}
|
||||
|
||||
@@ -251,6 +256,17 @@ class KiloBackendChatManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? {
|
||||
return messages(id, dir)
|
||||
.firstOrNull { it.info.id == message }
|
||||
?.parts
|
||||
?.firstOrNull {
|
||||
if (it.type != "file") return@firstOrNull false
|
||||
if (!key.isNullOrBlank()) attachmentKey(it.id, it.filename.orEmpty(), it.url.orEmpty()) == key
|
||||
else it.id == part
|
||||
}
|
||||
}
|
||||
|
||||
// ------ config update ------
|
||||
|
||||
fun updateConfig(dir: String, update: ConfigUpdateDto) {
|
||||
@@ -370,4 +386,10 @@ class KiloBackendChatManager(
|
||||
|
||||
private fun encode(value: String): String =
|
||||
java.net.URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private fun attachmentKey(part: String, name: String, url: String): String {
|
||||
val value = listOf(part, name, url).joinToString("\u0000")
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return bytes.take(16).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+135
-3
@@ -24,6 +24,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartTimeDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import ai.kilocode.rpc.dto.QuestionInfoDto
|
||||
import ai.kilocode.rpc.dto.QuestionOptionDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
@@ -68,6 +69,8 @@ object KiloCliDataParser {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val pretty = Json { ignoreUnknownKeys = true; prettyPrint = true }
|
||||
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
|
||||
private val READ_TOOL_LINE = Regex("^\\s*Called\\s+the\\s+Read\\s+tool\\s+with\\s+the\\s+following\\s+input:", RegexOption.IGNORE_CASE)
|
||||
private val READ_TOOL_PATH = Regex("\"(?:filePath|path)\"\\s*:")
|
||||
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
|
||||
|
||||
// ================================================================
|
||||
@@ -233,6 +236,88 @@ object KiloCliDataParser {
|
||||
}
|
||||
}
|
||||
|
||||
class ChatEventNormalizer {
|
||||
private val roles = mutableMapOf<String, String>()
|
||||
private val raw = mutableMapOf<Key, String>()
|
||||
private val text = mutableMapOf<Key, String>()
|
||||
|
||||
fun parse(type: String, data: String): List<ChatEventDto>? {
|
||||
val event = parseChatEvent(type, data) ?: return null
|
||||
return when (event) {
|
||||
is ChatEventDto.MessageUpdated -> {
|
||||
roles[event.info.id] = event.info.role
|
||||
listOf(event)
|
||||
}
|
||||
|
||||
is ChatEventDto.MessageRemoved -> {
|
||||
roles.remove(event.messageID)
|
||||
clear(event.messageID)
|
||||
listOf(event)
|
||||
}
|
||||
|
||||
is ChatEventDto.PartUpdated -> listOf(update(event))
|
||||
|
||||
is ChatEventDto.PartDelta -> delta(event)
|
||||
|
||||
is ChatEventDto.PartRemoved -> {
|
||||
val key = Key(event.messageID, event.partID)
|
||||
raw.remove(key)
|
||||
text.remove(key)
|
||||
listOf(event)
|
||||
}
|
||||
|
||||
else -> listOf(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(event: ChatEventDto.PartUpdated): ChatEventDto {
|
||||
val part = event.part
|
||||
val key = Key(part.messageID, part.id)
|
||||
if (roles[part.messageID] != "user" || part.type != "text") {
|
||||
raw.remove(key)
|
||||
text.remove(key)
|
||||
return event
|
||||
}
|
||||
|
||||
val value = part.text.orEmpty()
|
||||
val clean = sanitizeUserPromptText(value)
|
||||
raw[key] = value
|
||||
text[key] = clean
|
||||
return event.copy(part = part.copy(text = clean))
|
||||
}
|
||||
|
||||
private fun delta(event: ChatEventDto.PartDelta): List<ChatEventDto> {
|
||||
if (event.field != "text" || roles[event.messageID] != "user") return listOf(event)
|
||||
|
||||
val key = Key(event.messageID, event.partID)
|
||||
val prev = text[key].orEmpty()
|
||||
val next = raw[key].orEmpty() + event.delta
|
||||
val clean = sanitizeUserPromptText(next)
|
||||
raw[key] = next
|
||||
text[key] = clean
|
||||
|
||||
if (clean == prev) return emptyList()
|
||||
if (clean.startsWith(prev)) return listOf(event.copy(delta = clean.removePrefix(prev)))
|
||||
return listOf(ChatEventDto.PartUpdated(
|
||||
sessionID = event.sessionID,
|
||||
part = PartDto(
|
||||
id = event.partID,
|
||||
sessionID = event.sessionID,
|
||||
messageID = event.messageID,
|
||||
type = "text",
|
||||
text = clean,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
private fun clear(id: String) {
|
||||
raw.keys.filter { it.messageID == id }.forEach(raw::remove)
|
||||
text.keys.filter { it.messageID == id }.forEach(text::remove)
|
||||
}
|
||||
|
||||
private data class Key(val messageID: String, val partID: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair.
|
||||
* Returns null if the required fields are missing.
|
||||
@@ -267,14 +352,36 @@ object KiloCliDataParser {
|
||||
return arr.mapNotNull { elem ->
|
||||
val obj = elem.jsonObject
|
||||
val info = obj["info"]?.jsonObject ?: return@mapNotNull null
|
||||
val msg = parseMessage(info)
|
||||
val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList())
|
||||
MessageWithPartsDto(
|
||||
info = parseMessage(info),
|
||||
parts = parts.map { parsePart(it.jsonObject) },
|
||||
info = msg,
|
||||
parts = parts.map { sanitizePart(parsePart(it.jsonObject), msg.role) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizeUserPromptText(text: String): String {
|
||||
val lines = text.lines()
|
||||
if (lines.none(::readPayload)) return text
|
||||
|
||||
val out = mutableListOf<String>()
|
||||
var gap = false
|
||||
for (line in lines) {
|
||||
if (readPayload(line)) {
|
||||
gap = true
|
||||
continue
|
||||
}
|
||||
if (line.isBlank() && out.lastOrNull()?.isBlank() == true && gap) {
|
||||
gap = false
|
||||
continue
|
||||
}
|
||||
out.add(line)
|
||||
if (line.isNotBlank()) gap = false
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
fun parseCloudSessions(raw: String): CloudSessionListDto {
|
||||
val obj = tryParseObject(raw) ?: return CloudSessionListDto(emptyList())
|
||||
val items = obj["cliSessions"]?.jsonArray ?: JsonArray(emptyList())
|
||||
@@ -377,7 +484,7 @@ object KiloCliDataParser {
|
||||
*/
|
||||
fun buildPromptJson(prompt: PromptDto): String {
|
||||
val parts = prompt.parts.joinToString(",") { part ->
|
||||
"""{"type":"${part.type}","text":${escape(part.text)}}"""
|
||||
buildPromptPartJson(part)
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
sb.append("""{"parts":[$parts]""")
|
||||
@@ -406,6 +513,18 @@ object KiloCliDataParser {
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun buildPromptPartJson(part: PromptPartDto): String {
|
||||
val fields = mutableListOf("\"type\":${escape(part.type)}")
|
||||
if (part.type == "file") {
|
||||
part.mime?.let { fields += "\"mime\":${escape(it)}" }
|
||||
part.url?.let { fields += "\"url\":${escape(it)}" }
|
||||
part.filename?.let { fields += "\"filename\":${escape(it)}" }
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
fields += "\"text\":${escape(part.text.orEmpty())}"
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for `POST /session/{id}/summarize`.
|
||||
*/
|
||||
@@ -509,6 +628,9 @@ object KiloCliDataParser {
|
||||
messageID = obj.str("messageID") ?: "",
|
||||
type = obj.str("type") ?: "unknown",
|
||||
text = obj.str("text"),
|
||||
mime = obj.str("mime"),
|
||||
url = obj.str("url"),
|
||||
filename = obj.str("filename"),
|
||||
tool = obj.str("tool"),
|
||||
callID = obj.str("callID"),
|
||||
state = state?.str("status"),
|
||||
@@ -526,6 +648,16 @@ object KiloCliDataParser {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizePart(part: PartDto, role: String): PartDto {
|
||||
if (role != "user" || part.type != "text") return part
|
||||
return part.copy(text = part.text?.let(::sanitizeUserPromptText))
|
||||
}
|
||||
|
||||
private fun readPayload(line: String): Boolean {
|
||||
if (!READ_TOOL_LINE.containsMatchIn(line)) return false
|
||||
return READ_TOOL_PATH.containsMatchIn(line)
|
||||
}
|
||||
|
||||
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
|
||||
return parseTodosOrNull(raw) ?: emptyList()
|
||||
}
|
||||
|
||||
+4
@@ -16,6 +16,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -118,6 +119,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
|
||||
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
|
||||
ready { chat.messages(id, directory) }
|
||||
|
||||
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
|
||||
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }
|
||||
|
||||
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> =
|
||||
chat.events.filter { event ->
|
||||
val sid = when (event) {
|
||||
|
||||
+27
-2
@@ -216,10 +216,30 @@ class ChatDtoSerializationTest {
|
||||
assertEquals(2.0, decoded.time?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PartDto file fields are preserved in round-trip`() {
|
||||
val part = PartDto(
|
||||
id = "p1", sessionID = "s1", messageID = "m1",
|
||||
type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png",
|
||||
)
|
||||
val encoded = json.encodeToString(PartDto.serializer(), part)
|
||||
assertTrue(encoded.contains(""""mime":"image/png""""))
|
||||
assertTrue(encoded.contains(""""url":"file:///tmp/a.png""""))
|
||||
assertTrue(encoded.contains(""""filename":"a.png""""))
|
||||
|
||||
val decoded = json.decodeFromString(PartDto.serializer(), encoded)
|
||||
assertEquals("image/png", decoded.mime)
|
||||
assertEquals("file:///tmp/a.png", decoded.url)
|
||||
assertEquals("a.png", decoded.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PromptDto variant is preserved in round-trip`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(PromptPartDto("text", "hello")),
|
||||
parts = listOf(
|
||||
PromptPartDto("text", "hello"),
|
||||
PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"),
|
||||
),
|
||||
providerID = "kilo",
|
||||
modelID = "gpt-5",
|
||||
agent = "code",
|
||||
@@ -228,7 +248,12 @@ class ChatDtoSerializationTest {
|
||||
val encoded = json.encodeToString(PromptDto.serializer(), prompt)
|
||||
|
||||
assertTrue(encoded.contains(""""variant":"medium""""))
|
||||
assertEquals("medium", json.decodeFromString(PromptDto.serializer(), encoded).variant)
|
||||
val decoded = json.decodeFromString(PromptDto.serializer(), encoded)
|
||||
assertEquals("medium", decoded.variant)
|
||||
assertEquals("file", decoded.parts[1].type)
|
||||
assertEquals("image/png", decoded.parts[1].mime)
|
||||
assertEquals("file:///tmp/a.png", decoded.parts[1].url)
|
||||
assertEquals("a.png", decoded.parts[1].filename)
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
+20
@@ -154,6 +154,26 @@ class ChatLogSummaryTest {
|
||||
assertTrue(out.contains("variant=medium"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prompt dto summary redacts file attachment urls`() {
|
||||
System.setProperty("kilo.dev.log.chat.content", "preview")
|
||||
|
||||
val out = ChatLogSummary.prompt(
|
||||
PromptDto(
|
||||
parts = listOf(
|
||||
PromptPartDto(type = "text", text = "inspect"),
|
||||
PromptPartDto(type = "file", mime = "image/png", url = "file:///secret/path.png", filename = "path.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(out.contains("attachments=1"), out)
|
||||
assertTrue(out.contains("media=1"), out)
|
||||
assertTrue(out.contains("attachmentTypes=image/png"), out)
|
||||
assertFalse(out.contains("secret"), out)
|
||||
assertFalse(out.contains("file:///"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message updated summary includes role and model`() {
|
||||
val out = ChatLogSummary.event(
|
||||
|
||||
+240
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Nested
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
@@ -162,6 +163,107 @@ class KiloCliDataParserTest {
|
||||
assertEquals("Hello", result.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - file part preserves 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": "image/png",
|
||||
"url": "file:///tmp/a.png",
|
||||
"filename": "a.png"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data)
|
||||
assertNotNull(result)
|
||||
assertTrue(result is ChatEventDto.PartUpdated)
|
||||
assertEquals("file", result.part.type)
|
||||
assertEquals("image/png", result.part.mime)
|
||||
assertEquals("file:///tmp/a.png", result.part.url)
|
||||
assertEquals("a.png", result.part.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - user part updated sanitizes text`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val events = norm.parse("message.part.updated", partUpdated(
|
||||
"m1",
|
||||
"p1",
|
||||
"text",
|
||||
"before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter",
|
||||
))
|
||||
|
||||
val event = events!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("before\nafter", event.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - assistant part updated preserves text`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "assistant"))
|
||||
val payload = "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"
|
||||
|
||||
val events = norm.parse("message.part.updated", partUpdated("m1", "p1", "text", payload))
|
||||
|
||||
val event = events!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals(payload, event.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - user text deltas append normally`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello"))
|
||||
val second = norm.parse("message.part.delta", partDelta("m1", "p1", " world"))
|
||||
|
||||
assertEquals("hello", (first!!.single() as ChatEventDto.PartDelta).delta)
|
||||
assertEquals(" world", (second!!.single() as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - split generated payload delta is suppressed`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello\n"))
|
||||
val second = norm.parse(
|
||||
"message.part.delta",
|
||||
partDelta("m1", "p1", "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"),
|
||||
)
|
||||
|
||||
assertEquals("hello\n", (first!!.single() as ChatEventDto.PartDelta).delta)
|
||||
val event = second!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("hello", event.part.text)
|
||||
assertFalse(event.part.text!!.contains("Read tool"))
|
||||
assertFalse(event.part.text!!.contains("/tmp/a.kt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - partial noisy line is replaced when identified`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "before\nCalled the Read"))
|
||||
val second = norm.parse(
|
||||
"message.part.delta",
|
||||
partDelta("m1", "p1", " tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"),
|
||||
)
|
||||
|
||||
assertEquals("before\nCalled the Read", (first!!.single() as ChatEventDto.PartDelta).delta)
|
||||
val event = second!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("before\nafter", event.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - read tool part preserves input metadata and time`() {
|
||||
val data = globalEvent("""
|
||||
@@ -982,6 +1084,34 @@ class KiloCliDataParserTest {
|
||||
assertEquals("Hi there", result[1].parts[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseMessages - sanitizes user text read payloads only`() {
|
||||
val raw = """[
|
||||
{
|
||||
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
|
||||
"parts": [
|
||||
{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/user.kt\"}\nafter" },
|
||||
{ "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "filename": "a.png", "url": "file:///tmp/a.png" },
|
||||
{ "id": "t1", "sessionID": "s1", "messageID": "m1", "type": "tool", "tool": "read", "state": { "input": { "filePath": "/tmp/tool.kt" } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } },
|
||||
"parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}" }]
|
||||
}
|
||||
]"""
|
||||
|
||||
val result = KiloCliDataParser.parseMessages(raw)
|
||||
|
||||
assertEquals("before\nafter", result[0].parts[0].text)
|
||||
assertEquals("a.png", result[0].parts[1].filename)
|
||||
assertEquals("/tmp/tool.kt", result[0].parts[2].input["filePath"])
|
||||
assertEquals(
|
||||
"Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}",
|
||||
result[1].parts[0].text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseMessages - message with tool parts`() {
|
||||
val raw = """[{
|
||||
@@ -1370,6 +1500,48 @@ class KiloCliDataParserTest {
|
||||
assertTrue(result.contains("""line1\nline2\t\"quoted\""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - mixed text and file parts`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(
|
||||
PromptPartDto(type = "text", text = "see this"),
|
||||
PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"),
|
||||
)
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"parts":[{"type":"text","text":"see this"},{"type":"file","mime":"image/png","url":"file:///tmp/a.png","filename":"a.png"}]}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - file only omits optional filename`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(PromptPartDto(type = "file", mime = "application/pdf", url = "file:///tmp/a.pdf"))
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"parts":[{"type":"file","mime":"application/pdf","url":"file:///tmp/a.pdf"}]}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - escapes file metadata`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(PromptPartDto(type = "file", mime = "text/plain", url = "file:///tmp/a%20b.txt", filename = "a \"b\".txt"))
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertTrue(result.contains(""""filename":"a \"b\".txt""""), result)
|
||||
}
|
||||
|
||||
// ---- buildSummarizeJson ----
|
||||
|
||||
@Test
|
||||
@@ -1716,6 +1888,34 @@ class KiloCliDataParserTest {
|
||||
assertNull(result[0].message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - removes read payload lines`() {
|
||||
val text = "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter"
|
||||
|
||||
assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - handles read case variants and path key`() {
|
||||
val text = "before\nCalled the READ tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"
|
||||
|
||||
assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - preserves ordinary prose without path key`() {
|
||||
val text = "Called the Read tool with the following input: please inspect the file"
|
||||
|
||||
assertEquals(text, KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - collapses only blanks introduced by payload removal`() {
|
||||
val text = "before\n\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\n\nafter\n\n\nkeep"
|
||||
|
||||
assertEquals("before\n\nafter\n\n\nkeep", KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
@@ -1723,4 +1923,44 @@ class KiloCliDataParserTest {
|
||||
/** Wrap payload content in a GlobalEvent structure. */
|
||||
private fun globalEvent(payload: String): String =
|
||||
"""{"directory":"/tmp","payload":{$payload}}"""
|
||||
|
||||
private fun messageUpdated(id: String, role: String): String = globalEvent("""
|
||||
"type": "message.updated",
|
||||
"properties": {
|
||||
"sessionID": "s1",
|
||||
"info": { "id": "$id", "sessionID": "s1", "role": "$role", "time": { "created": 1.0 } }
|
||||
}
|
||||
""")
|
||||
|
||||
private fun partUpdated(mid: String, pid: String, type: String, text: String): String = globalEvent("""
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "s1",
|
||||
"part": { "id": "$pid", "sessionID": "s1", "messageID": "$mid", "type": "$type", "text": ${escape(text)} }
|
||||
}
|
||||
""")
|
||||
|
||||
private fun partDelta(mid: String, pid: String, delta: String): String = globalEvent("""
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "s1",
|
||||
"messageID": "$mid",
|
||||
"partID": "$pid",
|
||||
"field": "text",
|
||||
"delta": ${escape(delta)}
|
||||
}
|
||||
""")
|
||||
|
||||
private fun escape(text: String) = buildString {
|
||||
append('"')
|
||||
for (ch in text) {
|
||||
when (ch) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -185,6 +186,9 @@ class KiloSessionService internal constructor(
|
||||
call { messages(id, dir) }
|
||||
.also { LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } }
|
||||
|
||||
suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? =
|
||||
call { attachmentPart(id, dir, message, part, key) }
|
||||
|
||||
/** Subscribe to streaming chat events for a session. */
|
||||
fun events(id: String, dir: String): Flow<ChatEventDto> {
|
||||
val api = rpc
|
||||
|
||||
+1
@@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor(
|
||||
done(ok)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+110
-10
@@ -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.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.scroll.SessionScroll
|
||||
@@ -19,8 +20,13 @@ import ai.kilocode.client.session.ui.mode.ModePicker
|
||||
import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
|
||||
import ai.kilocode.client.session.ui.SessionDropOverlay
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind
|
||||
import ai.kilocode.client.session.ui.attachment.attachmentParams
|
||||
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
|
||||
import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment
|
||||
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
@@ -35,7 +41,10 @@ import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.client.settings.profile.UserProfileConfigurable
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.vfs.KiloVfsManager
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ide.BrowserUtil
|
||||
@@ -64,8 +73,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.HierarchyEvent
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.Timer
|
||||
import javax.swing.UIManager
|
||||
|
||||
/**
|
||||
@@ -89,6 +101,7 @@ class SessionUi(
|
||||
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(SessionUi::class.java)
|
||||
private const val HIDE_MS = 120
|
||||
}
|
||||
|
||||
private val project = project
|
||||
@@ -125,6 +138,13 @@ class SessionUi(
|
||||
|
||||
private lateinit var root: SessionRootPanel
|
||||
private lateinit var account: SessionAccountOverlay
|
||||
private lateinit var drop: SessionDropOverlay
|
||||
private val hide = Timer(HIDE_MS) {
|
||||
if (disposed || !this::drop.isInitialized) return@Timer
|
||||
drop.setActive(false)
|
||||
}.apply {
|
||||
isRepeats = false
|
||||
}
|
||||
|
||||
private lateinit var sessionContent: JPanel
|
||||
|
||||
@@ -296,6 +316,7 @@ class SessionUi(
|
||||
::openFile,
|
||||
::openUrl,
|
||||
selection,
|
||||
::openAttachment,
|
||||
repo = workspace.directory,
|
||||
resize = { anchor, fn -> scroll.preserve(anchor, fn) },
|
||||
)
|
||||
@@ -306,11 +327,21 @@ class SessionUi(
|
||||
|
||||
prompt = PromptPanel(
|
||||
project = project,
|
||||
onSend = { text -> sendPrompt(text) },
|
||||
onSend = { text, files -> sendPrompt(text, files) },
|
||||
onAbort = { controller.abort() },
|
||||
onEnhance = controller::enhancePrompt,
|
||||
)
|
||||
|
||||
drop = SessionDropOverlay()
|
||||
root.addOverlay(drop) { pane, _ ->
|
||||
java.awt.Rectangle(0, 0, pane.width, pane.height)
|
||||
}
|
||||
root.overlay.setComponentZOrder(drop, 0)
|
||||
prompt.onFileDrag = ::syncDrop
|
||||
prompt.installFileDrop(root, "session-root")
|
||||
// The visual overlay returns contains(false) so normal UI remains clickable.
|
||||
// Registering it as a native DnD target makes IntelliJ resolve a null over-component.
|
||||
|
||||
sessionContent.add(header, BorderLayout.NORTH)
|
||||
sessionContent.add(scroll.component, BorderLayout.CENTER)
|
||||
root.content.add(sessionContent, BorderLayout.CENTER)
|
||||
@@ -320,7 +351,10 @@ class SessionUi(
|
||||
|
||||
private fun bindUi() {
|
||||
prompt.mode.onSelect = { item -> controller.selectAgent(item.id) }
|
||||
prompt.model.onSelect = { item -> controller.selectModel(item.provider, item.id) }
|
||||
prompt.model.onSelect = { item ->
|
||||
prompt.setAttachmentEnabled(item.attachment)
|
||||
controller.selectModel(item.provider, item.id)
|
||||
}
|
||||
prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) }
|
||||
prompt.onReset = { controller.clearModelOverride() }
|
||||
prompt.onChange = { scroll.refresh() }
|
||||
@@ -356,14 +390,16 @@ class SessionUi(
|
||||
it.display,
|
||||
it.provider,
|
||||
it.providerName,
|
||||
it.recommendedIndex,
|
||||
it.free,
|
||||
it.variants,
|
||||
)
|
||||
it.recommendedIndex,
|
||||
it.free,
|
||||
it.variants,
|
||||
it.attachment,
|
||||
)
|
||||
}
|
||||
val selected =
|
||||
m.model?.let { full -> items.firstOrNull { it.key == full }?.key }
|
||||
prompt.model.setItems(items, selected)
|
||||
prompt.setAttachmentEnabled(items.firstOrNull { it.key == selected }?.attachment ?: true)
|
||||
prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant)
|
||||
prompt.setResetVisible(m.modelOverride)
|
||||
prompt.setReady(m.isReady())
|
||||
@@ -432,6 +468,17 @@ class SessionUi(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncDrop(value: Boolean) {
|
||||
if (disposed) return
|
||||
if (value) {
|
||||
hide.stop()
|
||||
drop.setActive(true)
|
||||
return
|
||||
}
|
||||
hide.restart()
|
||||
}
|
||||
|
||||
private fun bindMigration() {
|
||||
cs.launch {
|
||||
migration.state.collect { state ->
|
||||
@@ -515,16 +562,20 @@ class SessionUi(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPrompt(text: String) {
|
||||
if (text.isBlank()) return
|
||||
private fun sendPrompt(text: String, files: List<PromptPartDto>) {
|
||||
if (text.isBlank() && files.isEmpty()) return
|
||||
val parts = buildList {
|
||||
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
|
||||
addAll(files)
|
||||
}
|
||||
LOG.debug {
|
||||
val agent = controller.model.agent ?: "none"
|
||||
val model = controller.model.model ?: "none"
|
||||
"${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}"
|
||||
"${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}"
|
||||
}
|
||||
prompt.clear()
|
||||
val follow = scroll.atBottom()
|
||||
controller.prompt(text)
|
||||
controller.prompt(text, files)
|
||||
scroll.followBottom(follow)
|
||||
}
|
||||
|
||||
@@ -538,6 +589,54 @@ class SessionUi(
|
||||
BrowserUtil.browse(url)
|
||||
}
|
||||
|
||||
private fun openAttachment(messageId: String, item: FileAttachment) {
|
||||
val url = item.url.takeIf { it.isNotBlank() } ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
|
||||
return
|
||||
}
|
||||
LOG.info(
|
||||
"kind=attachment-open session=${controller.id ?: "none"} message=$messageId part=${item.id} " +
|
||||
"name=${attachmentName(item)} mime=${item.mime} url=${attachmentUrl(url)} dir=${workspace.directory}"
|
||||
)
|
||||
if (isEmbeddedAttachment(url)) {
|
||||
val id = controller.id ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=missing-session message=$messageId part=${item.id} name=${attachmentName(item)}")
|
||||
return
|
||||
}
|
||||
LOG.info("kind=attachment-open route=kilo-vfs session=$id message=$messageId part=${item.id} name=${attachmentName(item)}")
|
||||
ensureAttachmentEditorKind()
|
||||
project.service<KiloVfsManager>().open(
|
||||
AttachmentEditorKind.ID,
|
||||
attachmentParams(id, messageId, item, attachmentName(item), workspace.directory),
|
||||
)
|
||||
return
|
||||
}
|
||||
val uri = runCatching { URI.create(url) }.getOrNull() ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=invalid-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
|
||||
return
|
||||
}
|
||||
if (uri.scheme == "file") {
|
||||
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
|
||||
return
|
||||
}
|
||||
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path")
|
||||
openFile(path)
|
||||
return
|
||||
}
|
||||
LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}")
|
||||
openUrl(url)
|
||||
}
|
||||
|
||||
private fun attachmentName(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
|
||||
?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() }
|
||||
?: "attachment"
|
||||
|
||||
private fun attachmentUrl(url: String): String {
|
||||
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
|
||||
return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}"
|
||||
}
|
||||
|
||||
private fun onStateChanged(state: SessionState) {
|
||||
if (disposed) return
|
||||
prompt.setBusy(state.isBusy())
|
||||
@@ -596,6 +695,7 @@ class SessionUi(
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
hide.stop()
|
||||
modalFocus = null
|
||||
empty = null
|
||||
if (this::root.isInitialized) root.setModalContent(null)
|
||||
|
||||
+21
-12
@@ -229,13 +229,13 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
fun prompt(text: String) {
|
||||
fun prompt(text: String, files: List<PromptPartDto> = emptyList()) {
|
||||
assertEdt()
|
||||
val start = sid ?: ref?.key ?: "pending"
|
||||
val exists = sid != null
|
||||
val dto = promptDto(text)
|
||||
val props = promptProps()
|
||||
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
|
||||
val dto = promptDto(text, files)
|
||||
val props = promptProps(files)
|
||||
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
|
||||
capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf(
|
||||
"source" to "user",
|
||||
"hasExistingSession" to exists.toString(),
|
||||
@@ -658,11 +658,12 @@ class SessionController(
|
||||
info.name,
|
||||
provider.id,
|
||||
provider.name,
|
||||
info.recommendedIndex,
|
||||
info.free,
|
||||
info.variants,
|
||||
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
|
||||
)
|
||||
info.recommendedIndex,
|
||||
info.free,
|
||||
info.variants,
|
||||
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
|
||||
info.attachment,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -1237,12 +1238,16 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptDto(text: String): PromptDto {
|
||||
private fun promptDto(text: String, files: List<PromptPartDto> = emptyList()): PromptDto {
|
||||
val full = model.model
|
||||
val sel = full?.let(::parseModel)
|
||||
val variant = model.variant?.takeIf { it in model.variants }
|
||||
val parts = buildList {
|
||||
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
|
||||
addAll(files)
|
||||
}
|
||||
return PromptDto(
|
||||
parts = listOf(PromptPartDto(type = "text", text = text)),
|
||||
parts = parts,
|
||||
providerID = sel?.first,
|
||||
modelID = sel?.second,
|
||||
agent = model.agent,
|
||||
@@ -1455,7 +1460,7 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptProps(): Map<String, String> = buildMap {
|
||||
private fun promptProps(files: List<PromptPartDto> = emptyList()): Map<String, String> = buildMap {
|
||||
model.agent?.let { put("agent", it) }
|
||||
model.model?.let { key ->
|
||||
put("model", key)
|
||||
@@ -1465,6 +1470,10 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
model.variant?.takeIf { it in model.variants }?.let { put("variant", it) }
|
||||
if (files.isNotEmpty()) {
|
||||
put("attachmentCount", files.size.toString())
|
||||
put("mediaAttachmentCount", files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun bucket(text: String): String = when (text.length) {
|
||||
|
||||
+7
@@ -66,6 +66,13 @@ class Reasoning(id: String) : Content(id) {
|
||||
var done: Boolean = true
|
||||
}
|
||||
|
||||
/** User-provided file or image attachment. */
|
||||
class FileAttachment(id: String) : Content(id) {
|
||||
var mime: String = "application/octet-stream"
|
||||
var url: String = ""
|
||||
var filename: String? = null
|
||||
}
|
||||
|
||||
/** Tool invocation with lifecycle state. */
|
||||
class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
|
||||
var state: ToolExecState = ToolExecState.PENDING
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package ai.kilocode.client.session.model
|
||||
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import java.awt.Image
|
||||
import java.awt.image.BufferedImage
|
||||
import java.awt.image.MultiResolutionImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import javax.imageio.ImageIO
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.readBytes
|
||||
|
||||
data class PromptAttachment(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val url: String,
|
||||
val path: Path? = null,
|
||||
) {
|
||||
fun part() = PromptPartDto(
|
||||
type = "file",
|
||||
mime = mime,
|
||||
url = path?.let { data(it, mime) } ?: url,
|
||||
filename = name,
|
||||
)
|
||||
}
|
||||
|
||||
object PromptAttachmentExtractor {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
fun files(files: List<java.io.File>): List<PromptAttachment> = files
|
||||
.filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES }
|
||||
.map { file ->
|
||||
val path = file.toPath()
|
||||
val mime = mime(file)
|
||||
if (!media(mime)) return@map null
|
||||
PromptAttachment(
|
||||
id = path.toAbsolutePath().normalize().toString(),
|
||||
name = path.fileName?.toString() ?: path.name,
|
||||
mime = mime,
|
||||
url = path.toUri().toString(),
|
||||
path = path,
|
||||
)
|
||||
}
|
||||
.filterNotNull()
|
||||
|
||||
fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain"
|
||||
|
||||
fun image(raw: Any): PromptAttachment? {
|
||||
val image = when (raw) {
|
||||
is MultiResolutionImage -> raw.resolutionVariants.firstOrNull()?.buffered()
|
||||
is BufferedImage -> raw
|
||||
is Image -> raw.buffered()
|
||||
else -> null
|
||||
} ?: return null
|
||||
val out = ByteArrayOutputStream()
|
||||
ImageIO.write(image, "png", out)
|
||||
val id = UUID.randomUUID().toString()
|
||||
val data = Base64.getEncoder().encodeToString(out.toByteArray())
|
||||
return PromptAttachment(
|
||||
id = "clipboard-image:$id",
|
||||
name = "pasted-image-$id.png",
|
||||
mime = "image/png",
|
||||
url = "data:image/png;base64,$data",
|
||||
)
|
||||
}
|
||||
|
||||
private fun mime(file: java.io.File): String {
|
||||
if (file.isDirectory) return "application/x-directory"
|
||||
return when (file.extension.lowercase()) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"webp" -> "image/webp"
|
||||
"bmp" -> "image/bmp"
|
||||
"svg" -> "image/svg+xml"
|
||||
"pdf" -> "application/pdf"
|
||||
"txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp" -> "text/plain"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Image.buffered(): BufferedImage? {
|
||||
if (this is BufferedImage) return this
|
||||
val width = getWidth(null)
|
||||
val height = getHeight(null)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = image.createGraphics()
|
||||
try {
|
||||
g.drawImage(this, 0, 0, null)
|
||||
} finally {
|
||||
g.dispose()
|
||||
}
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
private fun data(path: Path, mime: String) = "data:$mime;base64,${Base64.getEncoder().encodeToString(path.readBytes())}"
|
||||
+21
@@ -158,6 +158,10 @@ class SessionModel {
|
||||
if (dto.type in SILENT_PART_TYPES) return
|
||||
val msg = entries[messageId] ?: return
|
||||
val existing = msg.parts[dto.id]
|
||||
if (empty(dto)) {
|
||||
if (existing is Text) removeContent(messageId, dto.id)
|
||||
return
|
||||
}
|
||||
if (existing != null) {
|
||||
updateExisting(messageId, existing, dto)
|
||||
return
|
||||
@@ -243,6 +247,7 @@ class SessionModel {
|
||||
val item = Message(msg.info)
|
||||
for (part in msg.parts) {
|
||||
if (part.type in SILENT_PART_TYPES) continue
|
||||
if (empty(part)) continue
|
||||
val content = fromDto(part, part.text)
|
||||
item.parts[content.id] = content
|
||||
}
|
||||
@@ -373,6 +378,11 @@ class SessionModel {
|
||||
existing.content.append(text)
|
||||
existing.done = dto.time?.end != null || dto.time == null
|
||||
}
|
||||
is FileAttachment -> {
|
||||
existing.mime = dto.mime ?: "application/octet-stream"
|
||||
existing.url = dto.url ?: ""
|
||||
existing.filename = dto.filename
|
||||
}
|
||||
is Tool -> {
|
||||
existing.kind = toolKind(dto.tool)
|
||||
existing.state = parseToolState(dto.state)
|
||||
@@ -398,6 +408,8 @@ class SessionModel {
|
||||
updateHeader()
|
||||
}
|
||||
|
||||
private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true
|
||||
|
||||
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
|
||||
val content = text ?: dto.text
|
||||
return when (dto.type) {
|
||||
@@ -408,6 +420,11 @@ class SessionModel {
|
||||
if (content != null && content.isNotEmpty()) this.content.append(content)
|
||||
done = dto.time?.end != null || dto.time == null
|
||||
}
|
||||
"file" -> FileAttachment(dto.id).apply {
|
||||
mime = dto.mime ?: "application/octet-stream"
|
||||
url = dto.url ?: ""
|
||||
filename = dto.filename
|
||||
}
|
||||
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
|
||||
state = parseToolState(dto.state)
|
||||
callId = dto.callID
|
||||
@@ -568,6 +585,7 @@ data class ModelItem(
|
||||
val free: Boolean,
|
||||
val variants: List<String>,
|
||||
val limit: ModelLimitItem?,
|
||||
val attachment: Boolean = false,
|
||||
) {
|
||||
val key: String get() = "$provider/$id"
|
||||
}
|
||||
@@ -602,6 +620,7 @@ private fun parseModelKey(value: String): Pair<String, String>? {
|
||||
private fun Content.timelineTitle(): String = when (this) {
|
||||
is Text -> "Text"
|
||||
is Reasoning -> "Reasoning"
|
||||
is FileAttachment -> filename?.takeIf { it.isNotBlank() } ?: "File"
|
||||
is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name
|
||||
is Compaction -> "Compaction"
|
||||
is StepFinish -> "Step finish"
|
||||
@@ -635,6 +654,7 @@ private fun tail(path: String): String {
|
||||
private fun Content.weight(): Int = when (this) {
|
||||
is Text -> content.length / 200 + 1
|
||||
is Reasoning -> content.length / 200 + 1
|
||||
is FileAttachment -> 1
|
||||
is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1
|
||||
is Compaction -> 2
|
||||
is StepFinish -> tokens?.stepWeight() ?: 1
|
||||
@@ -661,6 +681,7 @@ private fun renderMessage(msg: Message): List<String> {
|
||||
out.add("reasoning#${part.id} done=${part.done}:")
|
||||
out.addAll(renderText(part.content))
|
||||
}
|
||||
is FileAttachment -> out.add("file#${part.id} ${part.mime} ${part.filename ?: tail(part.url)}")
|
||||
is Tool -> out.add(renderTool(part))
|
||||
is Compaction -> out.add("compaction#${part.id}")
|
||||
is StepFinish -> out.add("step-finish#${part.id}")
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.IconUtil
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
|
||||
class SessionDropOverlay : BorderLayoutPanel() {
|
||||
private val title = KiloBundle.message("session.drop.files.title")
|
||||
private val subtitle = KiloBundle.message("session.drop.files.subtitle")
|
||||
private val text = "$title $subtitle"
|
||||
private val card = Card()
|
||||
private var active = false
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
accessibleContext?.accessibleName = text
|
||||
|
||||
val primary = JBLabel(title).apply {
|
||||
font = JBFont.h0()
|
||||
foreground = UIUtil.getLabelForeground()
|
||||
}
|
||||
val secondary = JBLabel(subtitle).apply {
|
||||
font = JBFont.h2()
|
||||
foreground = UIUtil.getLabelForeground()
|
||||
}
|
||||
val icon = JBLabel(IconUtil.scale(AllIcons.Actions.Download, null, 3f))
|
||||
val labels = Stack.vertical(JBUI.scale(SessionUiStyle.View.DropOverlay.LABEL_GAP))
|
||||
.next(primary.align(HAlign.CENTER, VAlign.CENTER))
|
||||
.next(secondary.align(HAlign.CENTER, VAlign.CENTER))
|
||||
.gap(JBUI.scale(SessionUiStyle.View.DropOverlay.ICON_GAP))
|
||||
.next(icon.align(HAlign.CENTER, VAlign.CENTER))
|
||||
card.apply {
|
||||
isVisible = false
|
||||
add(labels, BorderLayout.CENTER)
|
||||
}
|
||||
add(card.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setActive(value: Boolean) {
|
||||
if (active == value) return
|
||||
active = value
|
||||
card.isVisible = value
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean = false
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (!active) {
|
||||
super.paintComponent(g)
|
||||
return
|
||||
}
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.color = SessionUiStyle.View.DropOverlay.scrim()
|
||||
g2.fillRect(0, 0, width, height)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
private class Card : RoundedContentPanel(
|
||||
JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_HORIZONTAL_PADDING),
|
||||
) {
|
||||
override fun contentColor(): Color = SessionUiStyle.View.DropOverlay.card()
|
||||
|
||||
override fun outlineColor(): Color? = null
|
||||
|
||||
override fun cornerArc(): Int = JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_ARC)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.ToolCallRef
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
@@ -53,6 +54,7 @@ class SessionMessageListPanel(
|
||||
private val openFile: (String) -> Unit,
|
||||
private val openUrl: (String) -> Unit = {},
|
||||
private val selection: SessionSelection? = null,
|
||||
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> ai.kilocode.client.session.views.AttachmentView.openDefault(item, openFile, openUrl) },
|
||||
private val repo: String? = null,
|
||||
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
|
||||
) : SessionLayoutPanel(
|
||||
@@ -182,7 +184,7 @@ class SessionMessageListPanel(
|
||||
// ------ private event handlers ------
|
||||
|
||||
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover)
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover)
|
||||
turnViews[turn.id] = tv
|
||||
for (msgId in turn.messageIds) {
|
||||
val msg = model.message(msgId) ?: continue
|
||||
@@ -239,7 +241,7 @@ class SessionMessageListPanel(
|
||||
removeAll()
|
||||
|
||||
for (turn in model.turns()) {
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover)
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover)
|
||||
turnViews[turn.id] = tv
|
||||
for (msgId in turn.messageIds) {
|
||||
val msg = model.message(msgId) ?: continue
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Image
|
||||
import java.awt.LayoutManager2
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Path
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.Icon
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
data class AttachmentCardItem(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val url: String,
|
||||
val path: Path? = null,
|
||||
)
|
||||
|
||||
open class AttachmentCard(
|
||||
private val item: AttachmentCardItem,
|
||||
remove: (() -> Unit)? = null,
|
||||
open: (() -> Unit)? = null,
|
||||
) : JPanel(CardLayout()) {
|
||||
private var gen = 0
|
||||
private var loaded = false
|
||||
private val icon = attachmentIcon(item.mime, item.name)
|
||||
private val tip = tooltip(item)
|
||||
private val open = open?.let { callback ->
|
||||
object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
private val hover = object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
showAction(true)
|
||||
}
|
||||
|
||||
override fun mouseMoved(e: MouseEvent) {
|
||||
showAction(true)
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
val point = SwingUtilities.convertPoint(e.component, e.point, this@AttachmentCard)
|
||||
showAction(contains(point))
|
||||
}
|
||||
}
|
||||
private val preview = PreviewPanel(::watch).apply { setIcon(icon) }
|
||||
private val content = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.xs())
|
||||
add(preview, BorderLayout.CENTER)
|
||||
}
|
||||
private val action = remove?.let { callback ->
|
||||
CloseButton().apply {
|
||||
isVisible = false
|
||||
toolTipText = KiloBundle.message("prompt.attachment.remove", item.name)
|
||||
accessibleContext?.accessibleName = toolTipText
|
||||
addActionListener { callback() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
toolTipText = tip
|
||||
accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name)
|
||||
add(content)
|
||||
if (action != null) {
|
||||
add(action)
|
||||
setComponentZOrder(action, 0)
|
||||
}
|
||||
watch(this)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT,
|
||||
)
|
||||
|
||||
override fun getMinimumSize(): Dimension = preferredSize
|
||||
|
||||
override fun getMaximumSize(): Dimension = preferredSize
|
||||
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
load()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
|
||||
g2.color = SessionUiStyle.View.Surface.bgColor()
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
g2.color = SessionUiStyle.View.Outline.color()
|
||||
g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun load() {
|
||||
if (!item.mime.startsWith("image/")) return
|
||||
val stamp = ++gen
|
||||
val size = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
|
||||
)
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
val image = runCatching {
|
||||
val data = decodeDataImage(item.url)
|
||||
val path = local(item)
|
||||
if (data != null) ImageIO.read(ByteArrayInputStream(data)) else path?.let { ImageIO.read(it.toFile()) }
|
||||
}.getOrNull()
|
||||
val scaled = image?.let { scale(it, size.width, size.height) }
|
||||
if (scaled == null) return@executeOnPooledThread
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (gen != stamp || !isDisplayable) return@invokeLater
|
||||
preview.setIcon(ImageIcon(scaled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watch(node: Component) {
|
||||
if (node is JComponent && node !is JButton) node.toolTipText = tip
|
||||
node.removeMouseListener(hover)
|
||||
node.removeMouseMotionListener(hover)
|
||||
node.addMouseListener(hover)
|
||||
node.addMouseMotionListener(hover)
|
||||
open?.let {
|
||||
node.removeMouseListener(it)
|
||||
if (node !is JButton) {
|
||||
node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
node.addMouseListener(it)
|
||||
}
|
||||
}
|
||||
if (node is Container) node.components.forEach(::watch)
|
||||
}
|
||||
|
||||
private fun showAction(value: Boolean) {
|
||||
val button = action ?: return
|
||||
if (button.isVisible == value) return
|
||||
button.isVisible = value
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private class PreviewPanel(private val watch: (Component) -> Unit) : JPanel(BorderLayout()) {
|
||||
init {
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
|
||||
)
|
||||
|
||||
fun setIcon(next: Icon) {
|
||||
val label = JBLabel(next, SwingConstants.CENTER).align(HAlign.CENTER, VAlign.CENTER)
|
||||
removeAll()
|
||||
add(label, BorderLayout.CENTER)
|
||||
watch(label)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
|
||||
g2.color = SessionUiStyle.View.Surface.headerHoverBgColor()
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
}
|
||||
|
||||
private class CardLayout : LayoutManager2 {
|
||||
override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit
|
||||
override fun addLayoutComponent(name: String?, comp: Component) = Unit
|
||||
override fun removeLayoutComponent(comp: Component) = Unit
|
||||
override fun minimumLayoutSize(parent: Container) = preferredLayoutSize(parent)
|
||||
override fun preferredLayoutSize(parent: Container) = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT,
|
||||
)
|
||||
|
||||
override fun maximumLayoutSize(target: Container) = preferredLayoutSize(target)
|
||||
override fun getLayoutAlignmentX(target: Container) = 0f
|
||||
override fun getLayoutAlignmentY(target: Container) = 0f
|
||||
override fun invalidateLayout(target: Container) = Unit
|
||||
|
||||
override fun layoutContainer(parent: Container) {
|
||||
val size = JBUI.scale(SessionUiStyle.View.Attachment.CLOSE_SIZE)
|
||||
for (i in 0 until parent.componentCount) {
|
||||
val child = parent.getComponent(i)
|
||||
if (child is JButton) {
|
||||
child.setBounds(parent.width - size - UiStyle.Gap.xs(), UiStyle.Gap.xs(), size, size)
|
||||
continue
|
||||
}
|
||||
child.setBounds(0, 0, parent.width, parent.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CloseButton : JButton() {
|
||||
init {
|
||||
iconButton(this)
|
||||
icon = REMOVE_ICON
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
icon = REMOVE_HOVER_ICON
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
icon = REMOVE_ICON
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val REMOVE_ICON: Icon = IconLoader.getIcon("/icons/remove.svg", AttachmentCard::class.java)
|
||||
private val REMOVE_HOVER_ICON: Icon = IconLoader.getIcon("/icons/remove-hover.svg", AttachmentCard::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scale(image: Image, width: Int, height: Int): Image {
|
||||
val iw = image.getWidth(null)
|
||||
val ih = image.getHeight(null)
|
||||
if (iw <= 0 || ih <= 0) return image
|
||||
val ratio = minOf(width.toDouble() / iw, height.toDouble() / ih)
|
||||
val w = maxOf(1, (iw * ratio).toInt())
|
||||
val h = maxOf(1, (ih * ratio).toInt())
|
||||
return image.getScaledInstance(w, h, Image.SCALE_SMOOTH)
|
||||
}
|
||||
|
||||
private fun local(item: AttachmentCardItem): Path? {
|
||||
if (item.path != null) return item.path
|
||||
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return null
|
||||
if (uri.scheme != "file") return null
|
||||
return runCatching { Path.of(uri) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun tooltip(item: AttachmentCardItem): String = XmlStringUtil.wrapInHtml(
|
||||
StringUtil.escapeXmlEntities(
|
||||
KiloBundle.message("prompt.attachment.tooltip", item.name, item.mime, location(item)),
|
||||
).replace("\n", "<br>"),
|
||||
)
|
||||
|
||||
private fun location(item: AttachmentCardItem): String {
|
||||
if (item.path != null) return item.path.toString()
|
||||
val uri = runCatching { URI.create(item.url) }.getOrNull()
|
||||
if (uri?.scheme == "data") return KiloBundle.message("prompt.attachment.embedded")
|
||||
if (uri?.scheme == "file") return runCatching { Path.of(uri).toString() }
|
||||
.getOrElse { URLDecoder.decode(uri.rawSchemeSpecificPart.removePrefix("//"), StandardCharsets.UTF_8) }
|
||||
return item.url
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.vfs.KiloEditorKind
|
||||
import ai.kilocode.client.vfs.KiloEditorKindRegistry
|
||||
import ai.kilocode.client.vfs.KiloVirtualFile
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.components.ActionLink
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.ui.components.JBTextArea
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.Centerizer
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.MessageDigest
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.Icon
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal object AttachmentEditorKind : KiloEditorKind {
|
||||
const val ID = "attachment"
|
||||
|
||||
override val id: String = ID
|
||||
|
||||
override fun title(params: Map<String, String>): String = ref(params)?.filename ?: KiloBundle.message("session.attachment.title")
|
||||
override fun icon(params: Map<String, String>): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params))
|
||||
override fun presentablePath(params: Map<String, String>): String {
|
||||
val ref = ref(params)
|
||||
return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params))
|
||||
}
|
||||
|
||||
override fun isValid(params: Map<String, String>): Boolean = ref(params) != null
|
||||
|
||||
@RequiresEdt
|
||||
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
|
||||
val panel = JPanel(BorderLayout()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
}
|
||||
panel.add(component(AttachmentData.Connecting), BorderLayout.CENTER)
|
||||
val ref = ref(file.path.params)
|
||||
LOG.info("kind=attachment-editor phase=create-content valid=${ref != null} project=${project.name} hash=${project.locationHash} ref=${ref?.let(::brief) ?: "invalid"}")
|
||||
if (ref == null) {
|
||||
panel.removeAll()
|
||||
panel.add(component(AttachmentData.Missing), BorderLayout.CENTER)
|
||||
return panel
|
||||
}
|
||||
project.service<KiloAttachmentEditorService>().load(ref, parent) { data ->
|
||||
LOG.info("kind=attachment-editor phase=render data=${describe(data)} ref=${brief(ref)}")
|
||||
panel.removeAll()
|
||||
panel.add(component(data), BorderLayout.CENTER)
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun ref(params: Map<String, String>): AttachmentRef? {
|
||||
val session = params["sessionId"].takeIfPresent() ?: return null
|
||||
val message = params["messageId"].takeIfPresent() ?: return null
|
||||
val part = params["partId"].takeIfPresent() ?: return null
|
||||
val dir = params["directory"].takeIfPresent() ?: return null
|
||||
return AttachmentRef(
|
||||
directory = dir,
|
||||
sessionId = session,
|
||||
messageId = message,
|
||||
partId = part,
|
||||
attachmentKey = params["attachmentKey"].takeIfPresent(),
|
||||
filename = params["filename"].takeIfPresent() ?: part,
|
||||
mime = params["mime"].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private val LOG = KiloLog.create(AttachmentEditorKind::class.java)
|
||||
}
|
||||
|
||||
private fun component(data: AttachmentData): JComponent = when (data) {
|
||||
is AttachmentData.Text -> text(data.text)
|
||||
is AttachmentData.Image -> JBScrollPane(JBLabel(ImageIcon(data.image), SwingConstants.CENTER))
|
||||
is AttachmentData.Binary -> metadata(data.name, data.mime, data.size)
|
||||
is AttachmentData.Missing -> center(KiloBundle.message("session.attachment.missing"))
|
||||
is AttachmentData.Error -> center(KiloBundle.message("session.attachment.error", data.message))
|
||||
AttachmentData.Connecting -> connecting()
|
||||
AttachmentData.ConnectionFailed -> failed()
|
||||
}
|
||||
|
||||
private fun connecting(): JComponent {
|
||||
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(AnimatedIcon.Default()))
|
||||
next(JBLabel(KiloBundle.message("session.connection.connecting")))
|
||||
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
|
||||
}
|
||||
|
||||
private fun failed(): JComponent {
|
||||
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(KiloBundle.message("session.connection.error.app")))
|
||||
next(ActionLink(KiloBundle.message("session.connection.retry")) {
|
||||
service<KiloAppService>().retryAsync()
|
||||
})
|
||||
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
|
||||
}
|
||||
|
||||
private fun text(value: String): JComponent {
|
||||
val area = JBTextArea(value).apply {
|
||||
isEditable = false
|
||||
lineWrap = false
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm())
|
||||
}
|
||||
return JBScrollPane(area)
|
||||
}
|
||||
|
||||
private fun metadata(name: String, mime: String, size: Int): JComponent {
|
||||
return Stack.vertical(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(KiloBundle.message("session.attachment.unsupported", name)))
|
||||
next(JBLabel(KiloBundle.message("session.attachment.mime", mime.ifBlank { "unknown" })))
|
||||
next(JBLabel(KiloBundle.message("session.attachment.size", size)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun center(value: String): JComponent = Centerizer(JBLabel(value), Centerizer.TYPE.BOTH)
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
internal class KiloAttachmentEditorService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(KiloAttachmentEditorService::class.java)
|
||||
}
|
||||
|
||||
fun load(ref: AttachmentRef, parent: Disposable, done: (AttachmentData) -> Unit) {
|
||||
LOG.info("kind=attachment-load phase=start project=${project.name} hash=${project.locationHash} ref=${brief(ref)}")
|
||||
val disposed = AtomicBoolean(false)
|
||||
val job = cs.launch {
|
||||
val app = service<KiloAppService>()
|
||||
app.connect()
|
||||
while (!disposed.get()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) {
|
||||
LOG.info("kind=attachment-load phase=connecting ref=${brief(ref)}")
|
||||
done(AttachmentData.Connecting)
|
||||
}
|
||||
}
|
||||
val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR }
|
||||
LOG.info("kind=attachment-load phase=app-state status=${state.status} ref=${brief(ref)}")
|
||||
if (state.status == KiloAppStatusDto.ERROR) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) {
|
||||
LOG.info("kind=attachment-load phase=connection-failed ref=${brief(ref)}")
|
||||
done(AttachmentData.ConnectionFailed)
|
||||
}
|
||||
}
|
||||
app.state.first { it.status != KiloAppStatusDto.ERROR }
|
||||
continue
|
||||
}
|
||||
val data = runCatching { fetch(ref) }
|
||||
.getOrElse {
|
||||
LOG.warn("kind=attachment-load phase=fetch-error ref=${brief(ref)} message=${it.message}", it)
|
||||
AttachmentData.Error(it.message ?: it::class.java.simpleName)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) {
|
||||
LOG.info("kind=attachment-load phase=done data=${describe(data)} ref=${brief(ref)}")
|
||||
done(data)
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
Disposer.register(parent) {
|
||||
disposed.set(true)
|
||||
LOG.info("kind=attachment-load phase=dispose ref=${brief(ref)}")
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get()
|
||||
|
||||
private suspend fun fetch(ref: AttachmentRef): AttachmentData {
|
||||
val item = project.service<KiloSessionService>().attachmentPart(
|
||||
ref.sessionId,
|
||||
ref.directory,
|
||||
ref.messageId,
|
||||
ref.partId,
|
||||
ref.attachmentKey,
|
||||
) ?: run {
|
||||
LOG.info("kind=attachment-fetch result=missing reason=part-not-found session=${ref.sessionId} message=${ref.messageId} part=${ref.partId} key=${ref.attachmentKey ?: "none"}")
|
||||
return AttachmentData.Missing
|
||||
}
|
||||
val mode = if (ref.attachmentKey.isPresent()) "attachmentKey" else "partId"
|
||||
LOG.info("kind=attachment-fetch phase=matched mode=$mode session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=${item.filename.orEmpty()} mime=${item.mime.orEmpty()} url=${urlInfo(item.url.orEmpty())}")
|
||||
val data = parseDataUrl(item.url.orEmpty()) ?: run {
|
||||
LOG.info("kind=attachment-fetch result=missing reason=parse-data-url session=${ref.sessionId} message=${ref.messageId} part=${item.id} url=${urlInfo(item.url.orEmpty())}")
|
||||
return AttachmentData.Missing
|
||||
}
|
||||
val mime = item.mime?.takeIf { it.isNotBlank() } ?: data.mime
|
||||
val name = item.filename?.takeIf { it.isNotBlank() } ?: ref.filename
|
||||
LOG.info("kind=attachment-fetch phase=parsed session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=$name dtoMime=${item.mime.orEmpty()} dataMime=${data.mime} mime=$mime bytes=${data.bytes.size}")
|
||||
if (textual(mime)) return AttachmentData.Text(data.bytes.toString(Charsets.UTF_8))
|
||||
if (mime.startsWith("image/")) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val image = ImageIO.read(ByteArrayInputStream(data.bytes)) ?: return@withContext AttachmentData.Binary(name, mime, data.bytes.size)
|
||||
LOG.info("kind=attachment-fetch phase=image session=${ref.sessionId} message=${ref.messageId} part=${item.id} width=${image.width} height=${image.height} bytes=${data.bytes.size}")
|
||||
AttachmentData.Image(image)
|
||||
}
|
||||
}
|
||||
return AttachmentData.Binary(name, mime, data.bytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class AttachmentRef(
|
||||
val directory: String,
|
||||
val sessionId: String,
|
||||
val messageId: String,
|
||||
val partId: String,
|
||||
val attachmentKey: String?,
|
||||
val filename: String,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
fun ensureAttachmentEditorKind() {
|
||||
service<KiloEditorKindRegistry>().register(AttachmentEditorKind)
|
||||
}
|
||||
|
||||
internal fun attachmentParams(
|
||||
sessionId: String,
|
||||
messageId: String,
|
||||
item: FileAttachment,
|
||||
filename: String,
|
||||
directory: String,
|
||||
): Map<String, String> = linkedMapOf(
|
||||
"directory" to directory,
|
||||
"sessionId" to sessionId,
|
||||
"messageId" to messageId,
|
||||
"partId" to item.id,
|
||||
"attachmentKey" to attachmentKey(item.id, item.filename.orEmpty(), item.url),
|
||||
"filename" to filename,
|
||||
"mime" to item.mime,
|
||||
)
|
||||
|
||||
internal sealed interface AttachmentData {
|
||||
data class Text(val text: String) : AttachmentData
|
||||
data class Image(val image: BufferedImage) : AttachmentData
|
||||
data class Binary(val name: String, val mime: String, val size: Int) : AttachmentData
|
||||
data object Missing : AttachmentData
|
||||
data class Error(val message: String) : AttachmentData
|
||||
data object Connecting : AttachmentData
|
||||
data object ConnectionFailed : AttachmentData
|
||||
}
|
||||
|
||||
private fun brief(ref: AttachmentRef): String {
|
||||
return listOf(
|
||||
"sessionId=${ref.sessionId}",
|
||||
"messageId=${ref.messageId}",
|
||||
"partId=${ref.partId}",
|
||||
"attachmentKey=${ref.attachmentKey ?: ""}",
|
||||
"filename=${ref.filename}",
|
||||
"mime=${ref.mime}",
|
||||
"directory=${ref.directory}",
|
||||
).joinToString(prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
private fun String?.isPresent(): Boolean = !this.isNullOrBlank()
|
||||
private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() }
|
||||
|
||||
private fun describe(data: AttachmentData): String = when (data) {
|
||||
is AttachmentData.Text -> "text chars=${data.text.length}"
|
||||
is AttachmentData.Image -> "image width=${data.image.width} height=${data.image.height}"
|
||||
is AttachmentData.Binary -> "binary name=${data.name} mime=${data.mime} bytes=${data.size}"
|
||||
is AttachmentData.Error -> "error message=${data.message}"
|
||||
AttachmentData.Missing -> "missing"
|
||||
AttachmentData.Connecting -> "connecting"
|
||||
AttachmentData.ConnectionFailed -> "connection-failed"
|
||||
}
|
||||
|
||||
private fun urlInfo(url: String): String {
|
||||
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
|
||||
return "urlScheme=$scheme urlChars=${url.length} embedded=${isEmbeddedAttachment(url)}"
|
||||
}
|
||||
|
||||
private fun attachmentKey(part: String, name: String, url: String): String {
|
||||
val value = listOf(part, name, url).joinToString("\u0000")
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return bytes.take(16).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
import javax.swing.Icon
|
||||
|
||||
fun decodeDataImage(url: String): ByteArray? {
|
||||
val data = parseDataUrl(url) ?: return null
|
||||
if (!data.mime.startsWith("image/")) return null
|
||||
return data.bytes
|
||||
}
|
||||
|
||||
internal data class DataUrl(val mime: String, val bytes: ByteArray)
|
||||
|
||||
internal fun parseDataUrl(url: String): DataUrl? {
|
||||
if (!url.startsWith("data:")) return null
|
||||
val comma = url.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
val meta = url.substring(5, comma)
|
||||
val body = url.substring(comma + 1)
|
||||
val parts = meta.split(';').filter { it.isNotBlank() }
|
||||
val mime = parts.firstOrNull()?.takeIf { it.contains('/') } ?: "text/plain"
|
||||
val bytes = if (parts.any { it.equals("base64", ignoreCase = true) }) {
|
||||
runCatching { Base64.getDecoder().decode(body) }.getOrNull() ?: return null
|
||||
} else {
|
||||
URLDecoder.decode(body, StandardCharsets.UTF_8).toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
return DataUrl(mime, bytes)
|
||||
}
|
||||
|
||||
internal fun textual(mime: String) = mime.startsWith("text/") || mime in setOf(
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
)
|
||||
|
||||
internal fun attachmentIcon(mime: String, name: String = "attachment"): Icon = when {
|
||||
mime.startsWith("image/") -> AllIcons.FileTypes.Image
|
||||
mime == "application/x-directory" -> AllIcons.Nodes.Folder
|
||||
else -> FileTypeManager.getInstance().getFileTypeByFileName(name).icon ?: AllIcons.FileTypes.Text
|
||||
}
|
||||
|
||||
fun isEmbeddedAttachment(url: String) = url.startsWith("data:")
|
||||
|
||||
fun isLocalAttachment(url: String) = runCatching { java.net.URI.create(url).scheme == "file" }.getOrDefault(false)
|
||||
+1
@@ -57,6 +57,7 @@ class ModelPicker : PickerButton() {
|
||||
val recommendedIndex: Double? = null,
|
||||
val free: Boolean = false,
|
||||
val variants: List<String> = emptyList(),
|
||||
val attachment: Boolean = false,
|
||||
) {
|
||||
val key: String get() = "$provider/$id"
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ai.kilocode.client.session.ui.prompt
|
||||
|
||||
import com.intellij.ide.PasteProvider
|
||||
import com.intellij.ide.dnd.FileCopyPasteUtil
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.editor.actions.PasteAction
|
||||
import com.intellij.openapi.util.Key
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
|
||||
internal fun interface PromptAttachmentPasteHandler {
|
||||
fun paste(transferable: Transferable)
|
||||
}
|
||||
|
||||
internal val PROMPT_ATTACHMENT_PASTE_HANDLER_KEY: Key<PromptAttachmentPasteHandler> =
|
||||
Key.create("ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler")
|
||||
|
||||
internal class PromptAttachmentPasteProvider : PasteProvider {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
override fun isPastePossible(dataContext: DataContext): Boolean = transferable(dataContext) != null
|
||||
|
||||
override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext)
|
||||
|
||||
override fun performPaste(dataContext: DataContext) {
|
||||
val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return
|
||||
val handler = editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) ?: return
|
||||
val item = transferable(dataContext) ?: return
|
||||
handler.paste(item)
|
||||
}
|
||||
|
||||
private fun transferable(dataContext: DataContext): Transferable? {
|
||||
val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return null
|
||||
if (editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) == null) return null
|
||||
val item = dataContext.getData(PasteAction.TRANSFERABLE_PROVIDER)?.produce() ?: return null
|
||||
if (FileCopyPasteUtil.isFileListFlavorAvailable(item.transferDataFlavors)) return item
|
||||
if (item.isDataFlavorSupported(DataFlavor.imageFlavor)) return item
|
||||
return null
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package ai.kilocode.client.session.ui.prompt
|
||||
|
||||
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.ui.UiStyle
|
||||
import com.intellij.notification.Notification
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.FlowLayout
|
||||
import javax.swing.JPanel
|
||||
|
||||
class PromptAttachmentStrip(
|
||||
private val project: Project,
|
||||
private val removed: (PromptAttachment) -> Unit,
|
||||
) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), UiStyle.Gap.sm())) {
|
||||
private val chips = LinkedHashMap<String, PromptAttachmentChip>()
|
||||
|
||||
init {
|
||||
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
val count: Int get() = chips.size
|
||||
|
||||
@RequiresEdt
|
||||
fun add(item: PromptAttachment) {
|
||||
if (chips.containsKey(item.id)) return
|
||||
val chip = PromptAttachmentChip(project, item, remove = { removed(item) })
|
||||
chips[item.id] = chip
|
||||
add(chip)
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun remove(item: PromptAttachment) {
|
||||
val chip = chips.remove(item.id) ?: return
|
||||
remove(chip)
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun clear() {
|
||||
if (chips.isEmpty()) return
|
||||
chips.clear()
|
||||
removeAll()
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun sync() {
|
||||
isVisible = chips.isNotEmpty()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
|
||||
private class PromptAttachmentChip(
|
||||
project: Project,
|
||||
item: PromptAttachment,
|
||||
remove: () -> Unit,
|
||||
) : AttachmentCard(
|
||||
AttachmentCardItem(item.name, item.mime, item.url, item.path),
|
||||
remove = remove,
|
||||
open = { open(project, item) },
|
||||
) {
|
||||
companion object {
|
||||
private fun open(project: Project, item: PromptAttachment) {
|
||||
val path = item.path ?: return
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
val file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path)
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
if (file == null) {
|
||||
Notification("Kilo Code", KiloBundle.message("prompt.attachment.missing", item.name), NotificationType.WARNING).notify(project)
|
||||
return@invokeLater
|
||||
}
|
||||
FileEditorManager.getInstance(project).openFile(file, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
-5
@@ -5,6 +5,8 @@ import ai.kilocode.client.actions.SendPromptAction
|
||||
import ai.kilocode.client.actions.StopSessionAction
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.ReasoningPicker
|
||||
import ai.kilocode.client.session.model.PromptAttachment
|
||||
import ai.kilocode.client.session.model.PromptAttachmentExtractor
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
@@ -16,8 +18,12 @@ import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.DataManager
|
||||
import com.intellij.ide.dnd.DnDEvent
|
||||
import com.intellij.ide.dnd.DnDSupport
|
||||
import com.intellij.ide.dnd.FileCopyPasteUtil
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.actionSystem.ActionUiKind
|
||||
@@ -49,10 +55,13 @@ import java.awt.Cursor
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.Future
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.Icon
|
||||
@@ -66,7 +75,7 @@ import javax.swing.ScrollPaneConstants
|
||||
*/
|
||||
class PromptPanel(
|
||||
private val project: Project,
|
||||
private val onSend: (String) -> Unit,
|
||||
private val onSend: (String, List<PromptPartDto>) -> Unit,
|
||||
private val onAbort: () -> Unit,
|
||||
private val onEnhance: (String, (Result<String>) -> Unit) -> Unit,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext {
|
||||
@@ -88,10 +97,15 @@ class PromptPanel(
|
||||
var onReset: () -> Unit = {}
|
||||
var onChange: () -> Unit = {}
|
||||
var onAutoApproveToggle: (Boolean) -> Unit = {}
|
||||
var onFileDrag: (Boolean) -> Unit = {}
|
||||
private var style = SessionEditorStyle.current()
|
||||
private val shell = PromptShell()
|
||||
private val attachments = mutableListOf<PromptAttachment>()
|
||||
private val strip = PromptAttachmentStrip(project) { removeAttachment(it) }
|
||||
private var bus: MessageBusConnection? = null
|
||||
private var autoApprove = false
|
||||
private var attachment = true
|
||||
private var submitting = false
|
||||
|
||||
private val editor = PromptEditorTextField(project, this).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
@@ -109,8 +123,11 @@ class PromptPanel(
|
||||
ed.scrollPane.viewport.background = style.editorScheme.defaultBackground
|
||||
ed.settings.isUseSoftWraps = true
|
||||
ed.settings.isAdditionalPageAtBottom = false
|
||||
ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) })
|
||||
ed.scrollPane.horizontalScrollBarPolicy =
|
||||
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
installFileDrop(ed.contentComponent, "editor")
|
||||
installFileDrop(ed.scrollPane, "scroll")
|
||||
ed.contentComponent.addFocusListener(object : FocusAdapter() {
|
||||
override fun focusGained(e: FocusEvent) {
|
||||
shell.repaint()
|
||||
@@ -166,7 +183,7 @@ class PromptPanel(
|
||||
private var request = 0L
|
||||
|
||||
override val isSendEnabled: Boolean
|
||||
get() = ready && !busy && text().isNotEmpty()
|
||||
get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
|
||||
|
||||
override val isStopEnabled: Boolean
|
||||
get() = busy
|
||||
@@ -191,6 +208,7 @@ class PromptPanel(
|
||||
onChange()
|
||||
}
|
||||
})
|
||||
shell.add(strip, BorderLayout.NORTH)
|
||||
shell.add(editor, BorderLayout.CENTER)
|
||||
|
||||
val bar = BorderLayoutPanel().apply {
|
||||
@@ -213,6 +231,7 @@ class PromptPanel(
|
||||
bar.add(button)
|
||||
shell.add(bar, BorderLayout.SOUTH)
|
||||
add(shell, BorderLayout.CENTER)
|
||||
installFileDrop(shell, "shell")
|
||||
syncTooltip()
|
||||
syncAutoApprove()
|
||||
syncEnhance()
|
||||
@@ -224,6 +243,11 @@ class PromptPanel(
|
||||
if (!value) invalidateEnhancement() else syncEnhance()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setAttachmentEnabled(value: Boolean) {
|
||||
attachment = value
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setBusy(value: Boolean) {
|
||||
busy = value
|
||||
@@ -270,6 +294,8 @@ class PromptPanel(
|
||||
|
||||
internal fun buttonForTest(): JButton = button
|
||||
|
||||
internal fun attachmentCountForTest(): Int = attachments.size
|
||||
|
||||
internal val defaultFocusedComponent: JComponent get() = editor
|
||||
|
||||
@RequiresEdt
|
||||
@@ -285,9 +311,18 @@ class PromptPanel(
|
||||
@RequiresEdt
|
||||
fun clear() {
|
||||
editor.text = ""
|
||||
attachments.clear()
|
||||
strip.clear()
|
||||
syncEditorHeight()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun addAttachmentForTest(item: PromptAttachment) {
|
||||
addAttachment(item)
|
||||
}
|
||||
|
||||
internal fun processPasteForTest(transferable: Transferable): Future<*> = processPaste(transferable)
|
||||
|
||||
@RequiresEdt
|
||||
fun focus() {
|
||||
editor.requestFocusInWindow()
|
||||
@@ -359,12 +394,134 @@ class PromptPanel(
|
||||
private fun submit(src: String) {
|
||||
if (!isSendEnabled) return
|
||||
val txt = text()
|
||||
LOG.debug { "${ChatLogSummary.prompt(txt)} src=$src busy=$busy" }
|
||||
if (txt.isNotEmpty()) {
|
||||
onSend(txt)
|
||||
val items = attachments.toList()
|
||||
submitting = true
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
try {
|
||||
val files = items.map { it.part() }
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
submitting = false
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, files))} src=$src busy=$busy" }
|
||||
onSend(txt, files)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
submitting = false
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e)
|
||||
notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun addAttachment(item: PromptAttachment) {
|
||||
if (!attachment && PromptAttachmentExtractor.media(item.mime)) {
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" }
|
||||
notify(KiloBundle.message("prompt.attachment.unsupported.model"))
|
||||
return
|
||||
}
|
||||
if (attachments.any { it.id == item.id }) {
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=duplicate" }
|
||||
return
|
||||
}
|
||||
attachments += item
|
||||
strip.add(item)
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} count=${attachments.size}" }
|
||||
onChange()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun removeAttachment(item: PromptAttachment) {
|
||||
if (!attachments.removeIf { it.id == item.id }) return
|
||||
strip.remove(item)
|
||||
onChange()
|
||||
}
|
||||
|
||||
private fun promptDto(text: String, files: List<PromptPartDto>) = ai.kilocode.rpc.dto.PromptDto(
|
||||
parts = buildList {
|
||||
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
|
||||
addAll(files)
|
||||
}
|
||||
)
|
||||
|
||||
internal fun installFileDrop(target: JComponent, area: String) {
|
||||
LOG.debug { "kind=prompt-dnd install area=$area component=${target.javaClass.name}" }
|
||||
DnDSupport.createBuilder(target)
|
||||
.enableAsNativeTarget()
|
||||
.setTargetChecker { event ->
|
||||
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) {
|
||||
onFileDrag(false)
|
||||
LOG.debug { "kind=prompt-dnd check area=$area accept=false flavor=false" }
|
||||
return@setTargetChecker true
|
||||
}
|
||||
event.setDropPossible(true)
|
||||
onFileDrag(true)
|
||||
LOG.debug { "kind=prompt-dnd check area=$area accept=true flavor=true" }
|
||||
false
|
||||
}
|
||||
.setCleanUpOnLeaveCallback {
|
||||
onFileDrag(false)
|
||||
}
|
||||
.setDropHandlerWithResult { event ->
|
||||
val start = System.nanoTime()
|
||||
val files = dropFiles(event)
|
||||
val ms = elapsedMs(start)
|
||||
LOG.debug { "kind=prompt-dnd drop area=$area files=${files.size} extractMs=$ms queued=${files.isNotEmpty()}" }
|
||||
onFileDrag(false)
|
||||
if (files.isEmpty()) return@setDropHandlerWithResult false
|
||||
processAttachments("prompt-dnd", area, files, null, ms)
|
||||
true
|
||||
}
|
||||
.install()
|
||||
}
|
||||
|
||||
private fun processPaste(transferable: Transferable): Future<*> {
|
||||
return processAttachments("prompt-paste", "editor", null, transferable, 0)
|
||||
}
|
||||
|
||||
private fun processAttachments(
|
||||
kind: String,
|
||||
area: String,
|
||||
files: List<java.io.File>?,
|
||||
transferable: Transferable?,
|
||||
sourceMs: Long,
|
||||
): Future<*> {
|
||||
return ApplicationManager.getApplication().executeOnPooledThread {
|
||||
val start = System.nanoTime()
|
||||
try {
|
||||
val list = files ?: transferable?.let { FileCopyPasteUtil.getFileList(it).orEmpty() }.orEmpty()
|
||||
val image = transferable?.takeIf { list.isEmpty() && it.isDataFlavorSupported(DataFlavor.imageFlavor) }
|
||||
?.getTransferData(DataFlavor.imageFlavor)
|
||||
?.let(PromptAttachmentExtractor::image)
|
||||
val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image)
|
||||
val ms = elapsedMs(start)
|
||||
LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
|
||||
if (items.isEmpty()) return@executeOnPooledThread
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
|
||||
items.forEach(::addAttachment)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("kind=$kind extract area=$area failed message=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropFiles(event: DnDEvent): List<java.io.File> {
|
||||
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList()
|
||||
return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty()
|
||||
}
|
||||
|
||||
private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000
|
||||
|
||||
private fun notify(text: String) {
|
||||
com.intellij.notification.Notification("Kilo Code", text, com.intellij.notification.NotificationType.WARNING).notify(project)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun bindKeymap() {
|
||||
if (bus != null) return
|
||||
|
||||
+25
@@ -77,6 +77,31 @@ object SessionUiStyle {
|
||||
const val SHELL_HORIZONTAL_PADDING = 8
|
||||
}
|
||||
|
||||
/** Attachment preview card geometry. */
|
||||
object Attachment {
|
||||
const val CARD_WIDTH = 80
|
||||
const val CARD_HEIGHT = 59
|
||||
const val CLOSE_SIZE = 18
|
||||
const val CORNER_ARC = 8
|
||||
}
|
||||
|
||||
/** Full-session file drop overlay geometry and colors. */
|
||||
object DropOverlay {
|
||||
const val CARD_VERTICAL_PADDING = 16
|
||||
const val CARD_HORIZONTAL_PADDING = 20
|
||||
const val CARD_ARC = 12
|
||||
const val LABEL_GAP = 2
|
||||
const val ICON_GAP = 10
|
||||
private const val SCRIM_ALPHA = 210
|
||||
|
||||
fun scrim(): Color = JBColor.lazy {
|
||||
val bg = UiStyle.Colors.bg()
|
||||
Color(bg.red, bg.green, bg.blue, SCRIM_ALPHA)
|
||||
}
|
||||
|
||||
fun card(): Color = UiStyle.Colors.contentBackground()
|
||||
}
|
||||
|
||||
/** Reasoning block preview sizing. */
|
||||
object Reasoning {
|
||||
const val BODY_LINES = 5
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCard
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.FlowLayout
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
|
||||
class AttachmentView(
|
||||
private var item: FileAttachment,
|
||||
private val openAttachment: (FileAttachment) -> Unit,
|
||||
) : PartView() {
|
||||
constructor(
|
||||
item: FileAttachment,
|
||||
openFile: (String) -> Unit,
|
||||
openUrl: (String) -> Unit,
|
||||
) : this(item, { openDefault(it, openFile, openUrl) })
|
||||
|
||||
override val contentId: String = item.id
|
||||
private var chip = chip(item)
|
||||
|
||||
init {
|
||||
layout = FlowLayout(FlowLayout.LEFT, 0, UiStyle.Gap.xs())
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad())
|
||||
add(chip)
|
||||
}
|
||||
|
||||
override fun update(content: Content) {
|
||||
if (content !is FileAttachment) return
|
||||
if (same(content)) {
|
||||
item = content
|
||||
return
|
||||
}
|
||||
item = content
|
||||
remove(chip)
|
||||
chip = chip(content)
|
||||
add(chip)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}"
|
||||
|
||||
private fun chip(item: FileAttachment) = AttachmentCard(
|
||||
AttachmentCardItem(name(item), item.mime, item.url),
|
||||
open = { openAttachment(item) },
|
||||
)
|
||||
|
||||
private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename
|
||||
|
||||
companion object {
|
||||
fun openDefault(item: FileAttachment, openFile: (String) -> Unit, openUrl: (String) -> Unit) {
|
||||
val url = item.url.takeIf { it.isNotBlank() } ?: return
|
||||
val uri = runCatching { URI.create(url) }.getOrNull() ?: return
|
||||
if (uri.scheme == "file") {
|
||||
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return
|
||||
openFile(path)
|
||||
return
|
||||
}
|
||||
openUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
|
||||
?: tail(item.url).takeIf { it.isNotBlank() }
|
||||
?: "attachment"
|
||||
|
||||
private fun tail(value: String): String {
|
||||
val clean = value.trimEnd('/', '\\')
|
||||
val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
|
||||
if (index < 0) return clean
|
||||
return clean.substring(index + 1)
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.Message
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
import ai.kilocode.client.session.model.StepFinish
|
||||
@@ -15,6 +16,7 @@ import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
@@ -38,6 +40,7 @@ class MessageView(
|
||||
private var style: SessionEditorStyle = SessionEditorStyle.current(),
|
||||
private val openUrl: (String) -> Unit = {},
|
||||
private val selection: SessionSelection? = null,
|
||||
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) },
|
||||
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
|
||||
private val repo: String? = null,
|
||||
private val hover: ((PartView, Boolean) -> Unit)? = null,
|
||||
@@ -58,6 +61,7 @@ class MessageView(
|
||||
// so snapshot updates can append only deltas.
|
||||
private val aliases = LinkedHashMap<String, String>()
|
||||
private val sources = LinkedHashMap<String, String>()
|
||||
private var attachments: PromptAttachmentView? = null
|
||||
private var hidden: ToolCallRef? = null
|
||||
|
||||
init {
|
||||
@@ -112,6 +116,11 @@ class MessageView(
|
||||
}
|
||||
val existing = parts[content.id]
|
||||
if (existing != null) {
|
||||
if (existing is PromptAttachmentView && content is FileAttachment) {
|
||||
existing.upsert(content)
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if (ViewFactory.shouldReplace(existing, content)) {
|
||||
replacePart(content, existing)
|
||||
return
|
||||
@@ -126,6 +135,10 @@ class MessageView(
|
||||
}
|
||||
|
||||
private fun addPart(content: Content) {
|
||||
if (content is FileAttachment && role == SessionUiStyle.View.Message.USER_ROLE) {
|
||||
addAttachment(content)
|
||||
return
|
||||
}
|
||||
if (content is Reasoning) {
|
||||
val previous = parts.values.lastOrNull()
|
||||
if (previous is ReasoningView) {
|
||||
@@ -143,6 +156,19 @@ class MessageView(
|
||||
add(view)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun addAttachment(content: FileAttachment) {
|
||||
val view = attachments ?: PromptAttachmentView(msg.info.id) { openAttachment(msg.info.id, it) }.also {
|
||||
it.resize = resize
|
||||
it.hover = hover
|
||||
it.applyStyle(style)
|
||||
attachments = it
|
||||
add(it)
|
||||
}
|
||||
view.upsert(content)
|
||||
parts[content.id] = view
|
||||
}
|
||||
|
||||
private fun updateAlias(content: Reasoning, id: String) {
|
||||
val view = parts[id] as? ReasoningView ?: return
|
||||
val prev = sources[content.id].orEmpty()
|
||||
@@ -184,6 +210,14 @@ class MessageView(
|
||||
return
|
||||
}
|
||||
val view = parts.remove(contentId) ?: return
|
||||
if (view is PromptAttachmentView) {
|
||||
view.remove(contentId)
|
||||
if (!view.isEmpty()) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
attachments = null
|
||||
}
|
||||
aliases.values.removeAll { it == contentId }
|
||||
sources.keys.removeAll { it !in aliases }
|
||||
detach(view)
|
||||
@@ -199,6 +233,7 @@ class MessageView(
|
||||
*/
|
||||
private fun isHidden(content: Content): Boolean {
|
||||
if (content !is Tool) return false
|
||||
if (role == SessionUiStyle.View.Message.USER_ROLE && content.name == "read") return true
|
||||
if (content.name == "todoread") return true
|
||||
if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true
|
||||
val ref = hidden ?: return false
|
||||
@@ -212,7 +247,7 @@ class MessageView(
|
||||
* Called only when the hidden ref changes to avoid unnecessary rebuilds.
|
||||
*/
|
||||
private fun rebuildParts() {
|
||||
parts.values.forEach {
|
||||
parts.values.distinct().forEach {
|
||||
detach(it)
|
||||
remove(it)
|
||||
Disposer.dispose(it)
|
||||
@@ -220,6 +255,7 @@ class MessageView(
|
||||
parts.clear()
|
||||
aliases.clear()
|
||||
sources.clear()
|
||||
attachments = null
|
||||
for ((_, content) in msg.parts) {
|
||||
if (content is StepFinish) continue
|
||||
if (isHidden(content)) continue
|
||||
@@ -235,9 +271,9 @@ class MessageView(
|
||||
}
|
||||
|
||||
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
|
||||
ViewFactory.createUser(content, openFile, openUrl, selection, repo)
|
||||
ViewFactory.createUser(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
|
||||
} else {
|
||||
ViewFactory.create(content, openFile, openUrl, selection, repo)
|
||||
ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
|
||||
}
|
||||
|
||||
/** Append a streaming delta to the renderer for [contentId]. */
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCard
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Dimension
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
class PromptAttachmentView(
|
||||
messageId: String,
|
||||
private val openAttachment: (FileAttachment) -> Unit,
|
||||
) : PartView() {
|
||||
override val contentId: String = "attachments:$messageId"
|
||||
|
||||
private val items = LinkedHashMap<String, FileAttachment>()
|
||||
private val cards = LinkedHashMap<String, AttachmentCard>()
|
||||
private val row = Stack.horizontal(gap = UiStyle.Gap.sm())
|
||||
private val scroll = JBScrollPane(row).apply {
|
||||
border = null
|
||||
isOpaque = false
|
||||
viewport.isOpaque = false
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
}
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
)
|
||||
add(scroll)
|
||||
}
|
||||
|
||||
fun contains(id: String) = items.containsKey(id)
|
||||
|
||||
fun isEmpty() = items.isEmpty()
|
||||
|
||||
fun ids(): List<String> = items.keys.toList()
|
||||
|
||||
fun scrollPane(): JBScrollPane = scroll
|
||||
|
||||
@RequiresEdt
|
||||
fun upsert(item: FileAttachment) {
|
||||
val old = items[item.id]
|
||||
items[item.id] = item
|
||||
if (old != null && same(old, item)) return
|
||||
val next = card(item)
|
||||
val prev = cards.put(item.id, next)
|
||||
if (prev == null) {
|
||||
row.next(next)
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
val at = row.components.indexOfFirst { it === prev }.takeIf { it >= 0 } ?: return refresh()
|
||||
row.remove(prev)
|
||||
row.add(next, at)
|
||||
refresh()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun remove(id: String): Boolean {
|
||||
val item = items.remove(id) ?: return false
|
||||
cards.remove(item.id)?.let { row.remove(it) }
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun update(content: Content) {
|
||||
if (content is FileAttachment) upsert(content)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val ins = insets
|
||||
val pref = scroll.preferredSize
|
||||
return Dimension(0, pref.height + bar() + ins.top + ins.bottom)
|
||||
}
|
||||
|
||||
override fun getMinimumSize() = preferredSize
|
||||
|
||||
override fun doLayout() {
|
||||
val ins = insets
|
||||
scroll.setBounds(
|
||||
ins.left,
|
||||
ins.top,
|
||||
maxOf(0, width - ins.left - ins.right),
|
||||
maxOf(0, height - ins.top - ins.bottom),
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
row.removeAll()
|
||||
cards.clear()
|
||||
items.clear()
|
||||
}
|
||||
|
||||
override fun dumpLabel(): String = "PromptAttachmentView#$contentId[${items.keys.joinToString(",")}]"
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun card(item: FileAttachment) = AttachmentCard(
|
||||
AttachmentCardItem(name(item), item.mime, item.url),
|
||||
open = { openAttachment(item) },
|
||||
)
|
||||
|
||||
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename
|
||||
|
||||
private fun bar() = scroll.horizontalScrollBar.preferredSize.height
|
||||
|
||||
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
|
||||
?: tail(item.url).takeIf { it.isNotBlank() }
|
||||
?: "attachment"
|
||||
|
||||
private fun tail(value: String): String {
|
||||
val clean = value.trimEnd('/', '\\')
|
||||
val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
|
||||
if (index < 0) return clean
|
||||
return clean.substring(index + 1)
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,5 +1,6 @@
|
||||
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.ui.SessionLayoutPanel
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
@@ -27,6 +28,7 @@ class TurnView(
|
||||
private var style: SessionEditorStyle = SessionEditorStyle.current(),
|
||||
private val openUrl: (String) -> Unit = {},
|
||||
private val selection: SessionSelection? = null,
|
||||
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) },
|
||||
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
|
||||
private val repo: String? = null,
|
||||
private val hover: ((PartView, Boolean) -> Unit)? = null,
|
||||
@@ -42,7 +44,7 @@ class TurnView(
|
||||
|
||||
/** Add a new [MessageView] for [msg] at the end of this turn. */
|
||||
fun addMessage(msg: Message): MessageView {
|
||||
val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo, hover)
|
||||
val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover)
|
||||
messages[msg.info.id] = view
|
||||
add(view)
|
||||
revalidate()
|
||||
|
||||
+5
-13
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.model.Compaction
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.Generic
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
import ai.kilocode.client.session.model.StepFinish
|
||||
@@ -31,21 +32,17 @@ object ViewFactory {
|
||||
openFile: (String) -> Unit,
|
||||
): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null)
|
||||
|
||||
fun create(
|
||||
content: Content,
|
||||
openFile: (String) -> Unit,
|
||||
openUrl: (String) -> Unit,
|
||||
): PartView = create(content, openFile, openUrl, selection = null, repo = null)
|
||||
|
||||
fun create(
|
||||
content: Content,
|
||||
openFile: (String) -> Unit,
|
||||
openUrl: (String) -> Unit = {},
|
||||
selection: SessionSelection? = null,
|
||||
repo: String? = null,
|
||||
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
|
||||
): PartView = when (content) {
|
||||
is Text -> TextView(content, openUrl = openUrl, selection = selection)
|
||||
is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection)
|
||||
is FileAttachment -> AttachmentView(content, openAttachment)
|
||||
is Tool -> when {
|
||||
TodoWriteView.canRender(content) -> TodoWriteView(content)
|
||||
PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection)
|
||||
@@ -65,21 +62,16 @@ object ViewFactory {
|
||||
openFile: (String) -> Unit,
|
||||
): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null)
|
||||
|
||||
fun createUser(
|
||||
content: Content,
|
||||
openFile: (String) -> Unit,
|
||||
openUrl: (String) -> Unit,
|
||||
): PartView = createUser(content, openFile, openUrl, selection = null, repo = null)
|
||||
|
||||
fun createUser(
|
||||
content: Content,
|
||||
openFile: (String) -> Unit,
|
||||
openUrl: (String) -> Unit = {},
|
||||
selection: SessionSelection? = null,
|
||||
repo: String? = null,
|
||||
openAttachment: (FileAttachment) -> Unit = { AttachmentView.openDefault(it, openFile, openUrl) },
|
||||
): PartView = when (content) {
|
||||
is Text -> PromptView(content, openUrl = openUrl, selection = selection)
|
||||
else -> create(content, openFile, openUrl, selection, repo)
|
||||
else -> create(content, openFile, openUrl, selection, repo, openAttachment)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-10
@@ -14,6 +14,7 @@ import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import ai.kilocode.cli.KiloCliParser
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
@@ -539,19 +540,11 @@ internal data class Target(
|
||||
internal fun target(tool: Tool): Target? {
|
||||
val out = output(tool)
|
||||
if (out.isBlank()) return null
|
||||
val path = tag(out, "path") ?: return null
|
||||
val type = tag(out, "type") ?: return null
|
||||
val path = KiloCliParser.tag(out, "path") ?: return null
|
||||
val type = KiloCliParser.tag(out, "type") ?: return null
|
||||
return Target(path, type.lowercase())
|
||||
}
|
||||
|
||||
private fun tag(text: String, name: String): String? =
|
||||
Regex("<$name>\\s*([\\s\\S]*?)\\s*</$name>")
|
||||
.find(text)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun shellTitle(tool: Tool): String =
|
||||
tool.input["description"]?.takeIf { it.isNotBlank() }
|
||||
?: tool.metadata["description"]?.takeIf { it.isNotBlank() }
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ open class LayeredOverlayPanel(
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean {
|
||||
for (child in components) {
|
||||
if (child.isVisible && child.bounds.contains(x, y)) return true
|
||||
if (child.isVisible && child.bounds.contains(x, y) && child.contains(x - child.x, y - child.y)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import javax.swing.JComponent
|
||||
|
||||
interface KiloEditorKind : KiloVirtualFileKind {
|
||||
@RequiresEdt
|
||||
fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent
|
||||
|
||||
fun preferredFocus(component: JComponent): JComponent? = null
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Service(Service.Level.APP)
|
||||
class KiloEditorKindRegistry {
|
||||
private val kinds = ConcurrentHashMap<String, KiloEditorKind>()
|
||||
|
||||
fun register(kind: KiloEditorKind) {
|
||||
kinds[kind.id] = kind
|
||||
service<KiloVirtualFileKindRegistry>().register(kind)
|
||||
}
|
||||
|
||||
fun unregister(id: String) {
|
||||
kinds.remove(id)
|
||||
service<KiloVirtualFileKindRegistry>().unregister(id)
|
||||
}
|
||||
|
||||
fun get(id: String): KiloEditorKind? = kinds[id]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import javax.swing.JComponent
|
||||
|
||||
class KiloFileEditor(
|
||||
private val project: Project,
|
||||
private val file: VirtualFile,
|
||||
private val kilo: KiloVirtualFile,
|
||||
private val kind: KiloEditorKind,
|
||||
) : KiloFileEditorBase() {
|
||||
private val ui: JComponent by lazy { kind.createContent(project, kilo, this) }
|
||||
|
||||
@RequiresEdt
|
||||
override fun getComponent(): JComponent = ui
|
||||
|
||||
override fun getPreferredFocusedComponent(): JComponent? = kind.preferredFocus(ui)
|
||||
override fun getName(): String = kind.title(kilo.path.params)
|
||||
override fun getFile(): VirtualFile = file
|
||||
override fun isValid(): Boolean = super.isValid() && kilo.isValid
|
||||
|
||||
override fun dispose() {
|
||||
KiloVirtualFileSystem.getInstance().release(kilo.path)
|
||||
super.dispose()
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.fileEditor.FileEditor
|
||||
import com.intellij.openapi.fileEditor.FileEditorState
|
||||
import com.intellij.openapi.fileEditor.FileEditorStateLevel
|
||||
import com.intellij.openapi.util.CheckedDisposable
|
||||
import com.intellij.openapi.util.UserDataHolderBase
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.beans.PropertyChangeSupport
|
||||
|
||||
abstract class KiloFileEditorBase : UserDataHolderBase(), FileEditor, CheckedDisposable {
|
||||
private var disposed = false
|
||||
private val support = PropertyChangeSupport(this)
|
||||
|
||||
override fun isDisposed(): Boolean = disposed
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
}
|
||||
|
||||
override fun isValid(): Boolean = !disposed
|
||||
|
||||
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
|
||||
support.addPropertyChangeListener(listener)
|
||||
}
|
||||
|
||||
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
|
||||
support.removePropertyChangeListener(listener)
|
||||
}
|
||||
|
||||
override fun getState(level: FileEditorStateLevel): FileEditorState = FileEditorState.INSTANCE
|
||||
override fun setState(state: FileEditorState) {}
|
||||
override fun isModified(): Boolean = false
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileEditor
|
||||
import com.intellij.openapi.fileEditor.FileEditorPolicy
|
||||
import com.intellij.openapi.fileEditor.FileEditorProvider
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
|
||||
class KiloFileEditorProvider : FileEditorProvider, DumbAware {
|
||||
override fun accept(project: Project, file: VirtualFile): Boolean {
|
||||
ensureAttachmentEditorKind()
|
||||
val path = path(file) ?: return false
|
||||
return service<KiloEditorKindRegistry>().get(path.kind) != null
|
||||
}
|
||||
|
||||
override fun acceptRequiresReadAction(): Boolean = false
|
||||
|
||||
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
|
||||
ensureAttachmentEditorKind()
|
||||
val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}")
|
||||
val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path)
|
||||
val kind = service<KiloEditorKindRegistry>().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}")
|
||||
return KiloFileEditor(project, file, kilo, kind)
|
||||
}
|
||||
|
||||
override fun disposeEditor(editor: FileEditor) {
|
||||
Disposer.dispose(editor)
|
||||
}
|
||||
|
||||
override fun getEditorTypeId(): String = EDITOR_TYPE_ID
|
||||
override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_OTHER_EDITORS
|
||||
|
||||
companion object {
|
||||
const val EDITOR_TYPE_ID = "KiloVfsEditor"
|
||||
|
||||
private fun path(file: VirtualFile): KiloPath? {
|
||||
if (file is KiloVirtualFile) return file.path
|
||||
if (file.fileSystem.protocol != KiloVirtualFileSystem.PROTOCOL && !file.url.startsWith("${KiloVirtualFileSystem.PROTOCOL}://")) return null
|
||||
return KiloVirtualFileSystem.decode(file.path) ?: KiloVirtualFileSystem.decode(file.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KiloPath(
|
||||
val kind: String,
|
||||
val params: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
fun canonical(): KiloPath = copy(params = canonicalParams(params))
|
||||
}
|
||||
|
||||
internal fun canonicalParams(params: Map<String, String>): Map<String, String> {
|
||||
if (params.size < 2) return params
|
||||
return params.toSortedMap()
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorProvider
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
class KiloVfsManager(private val project: Project) {
|
||||
@RequiresEdt
|
||||
fun open(kind: String, params: Map<String, String> = emptyMap(), focus: Boolean = true): Boolean {
|
||||
val file = file(kind, params) ?: return false
|
||||
if (ApplicationManager.getApplication().isUnitTestMode) {
|
||||
file.putUserData(FileEditorProvider.KEY, KiloFileEditorProvider())
|
||||
}
|
||||
FileEditorManager.getInstance(project).openFile(file, focus)
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun close(kind: String, params: Map<String, String> = emptyMap()) {
|
||||
val file = file(kind, params) ?: return
|
||||
FileEditorManager.getInstance(project).closeFile(file)
|
||||
KiloVirtualFileSystem.getInstance().release(file.path)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun updatePresentation(kind: String, params: Map<String, String> = emptyMap()) {
|
||||
val file = file(kind, params) ?: return
|
||||
FileEditorManager.getInstance(project).updateFilePresentation(file)
|
||||
}
|
||||
|
||||
private fun file(kind: String, params: Map<String, String>): KiloVirtualFile? {
|
||||
val path = KiloPath(kind, params)
|
||||
val fs = KiloVirtualFileSystem.getInstance()
|
||||
return fs.refreshAndFindFileByPath(fs.getPath(path)) as? KiloVirtualFile
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
@file:Suppress("LeakingThis")
|
||||
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileEditorManagerKeys
|
||||
import com.intellij.openapi.fileTypes.FileType
|
||||
import com.intellij.openapi.fileTypes.FileTypes
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.VirtualFilePathWrapper
|
||||
import com.intellij.openapi.vfs.VirtualFileWithoutContent
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class KiloVirtualFile(
|
||||
val path: KiloPath,
|
||||
) : VirtualFile(),
|
||||
VirtualFileWithoutContent,
|
||||
VirtualFilePathWrapper {
|
||||
init {
|
||||
putUserData(FileEditorManagerKeys.FORBID_TAB_SPLIT, true)
|
||||
}
|
||||
|
||||
override fun getFileSystem(): KiloVirtualFileSystem = KiloVirtualFileSystem.getInstance()
|
||||
override fun getFileType(): FileType = FileTypes.UNKNOWN
|
||||
override fun getPath(): String = fileSystem.getPath(path)
|
||||
override fun getUrl(): String = "${fileSystem.protocol}://$path"
|
||||
override fun getName(): String = kind()?.title(path.params) ?: path.kind
|
||||
override fun getPresentableName(): String = name
|
||||
override fun getPresentablePath(): String = kind()?.presentablePath(path.params) ?: name
|
||||
override fun enforcePresentableName(): Boolean = true
|
||||
override fun isValid(): Boolean = kind()?.isValid(path.params) == true
|
||||
override fun isWritable(): Boolean = false
|
||||
override fun isDirectory(): Boolean = false
|
||||
override fun getParent(): VirtualFile? = null
|
||||
override fun getChildren(): Array<VirtualFile> = emptyArray()
|
||||
override fun getLength(): Long = 0
|
||||
override fun getTimeStamp(): Long = 0
|
||||
override fun getModificationStamp(): Long = 0
|
||||
override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) {
|
||||
postRunnable?.run()
|
||||
}
|
||||
|
||||
override fun contentsToByteArray(): ByteArray = throw UnsupportedOperationException()
|
||||
override fun getInputStream(): InputStream = throw UnsupportedOperationException()
|
||||
override fun getOutputStream(requestor: Any?, newModificationStamp: Long, newTimeStamp: Long): OutputStream =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is KiloVirtualFile) return false
|
||||
return path == other.path
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = path.hashCode()
|
||||
|
||||
private fun kind(): KiloVirtualFileKind? = service<KiloVirtualFileKindRegistry>().get(path.kind)
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import javax.swing.Icon
|
||||
|
||||
interface KiloVirtualFileKind {
|
||||
val id: String
|
||||
|
||||
fun title(params: Map<String, String>): String
|
||||
|
||||
fun icon(params: Map<String, String>): Icon? = null
|
||||
|
||||
fun presentablePath(params: Map<String, String>): String = title(params)
|
||||
|
||||
fun isValid(params: Map<String, String>): Boolean = true
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.components.Service
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Service(Service.Level.APP)
|
||||
class KiloVirtualFileKindRegistry {
|
||||
private val kinds = ConcurrentHashMap<String, KiloVirtualFileKind>()
|
||||
|
||||
fun register(kind: KiloVirtualFileKind) {
|
||||
kinds[kind.id] = kind
|
||||
}
|
||||
|
||||
fun unregister(id: String) {
|
||||
kinds.remove(id)
|
||||
}
|
||||
|
||||
fun get(id: String): KiloVirtualFileKind? = kinds[id]
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package ai.kilocode.client.vfs
|
||||
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.logger
|
||||
import com.intellij.openapi.vfs.NonPhysicalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.VirtualFileListener
|
||||
import com.intellij.openapi.vfs.VirtualFilePathWrapper
|
||||
import com.intellij.openapi.vfs.VirtualFileSystem
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class KiloVirtualFileSystem : VirtualFileSystem(), NonPhysicalFileSystem {
|
||||
private val files = ConcurrentHashMap<KiloPath, KiloVirtualFile>()
|
||||
|
||||
fun getPath(path: KiloPath): String = json.encodeToString(KiloPath.serializer(), path.canonical())
|
||||
|
||||
fun findOrCreateFile(path: KiloPath): VirtualFile? {
|
||||
service<KiloVirtualFileKindRegistry>().get(path.kind) ?: return null
|
||||
return files.computeIfAbsent(path.canonical()) { KiloVirtualFile(it) }
|
||||
}
|
||||
|
||||
fun release(path: KiloPath) {
|
||||
files.remove(path.canonical())
|
||||
}
|
||||
|
||||
override fun findFileByPath(path: String): VirtualFile? {
|
||||
val parsed = decode(path) ?: return null
|
||||
return findOrCreateFile(parsed)
|
||||
}
|
||||
|
||||
override fun refreshAndFindFileByPath(path: String): VirtualFile? = findFileByPath(path)
|
||||
|
||||
override fun extractPresentableUrl(path: String): String {
|
||||
return (refreshAndFindFileByPath(path) as? VirtualFilePathWrapper)?.presentablePath ?: path
|
||||
}
|
||||
|
||||
override fun refresh(asynchronous: Boolean) {}
|
||||
|
||||
override fun getProtocol(): String = PROTOCOL
|
||||
|
||||
override fun addVirtualFileListener(listener: VirtualFileListener) {}
|
||||
|
||||
override fun removeVirtualFileListener(listener: VirtualFileListener) {}
|
||||
override fun isReadOnly(): Boolean = true
|
||||
override fun deleteFile(requestor: Any?, file: VirtualFile) = unsupported()
|
||||
override fun moveFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile) = unsupported()
|
||||
override fun renameFile(requestor: Any?, file: VirtualFile, newName: String) = unsupported()
|
||||
override fun createChildFile(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported()
|
||||
override fun createChildDirectory(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported()
|
||||
override fun copyFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile, copyName: String): VirtualFile = unsupported()
|
||||
|
||||
private fun unsupported(): Nothing = throw UnsupportedOperationException("Kilo virtual files are read-only")
|
||||
|
||||
companion object {
|
||||
const val PROTOCOL = "kilo"
|
||||
|
||||
private val json = Json
|
||||
private val log = logger<KiloVirtualFileSystem>()
|
||||
private val local = KiloVirtualFileSystem()
|
||||
|
||||
fun getInstance(): KiloVirtualFileSystem = local
|
||||
|
||||
fun decode(path: String): KiloPath? {
|
||||
return try {
|
||||
val raw = raw(path) ?: return null
|
||||
json.decodeFromString(KiloPath.serializer(), raw).canonical()
|
||||
} catch (err: Exception) {
|
||||
log.warn("Cannot deserialize $path", err)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun raw(path: String): String? {
|
||||
if (path.startsWith("{")) return path
|
||||
if (!path.startsWith("$PROTOCOL://")) return null
|
||||
val raw = path.substringAfter("://")
|
||||
if (raw.startsWith("{")) return raw
|
||||
if (!raw.startsWith("%7B", ignoreCase = true)) return null
|
||||
return URLDecoder.decode(raw, StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#C84242"/>
|
||||
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#DB5860"/>
|
||||
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#6C707E"/>
|
||||
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7" fill="#CED0D6"/>
|
||||
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#1E1F22" stroke-width="1.6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -9,6 +9,7 @@
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<applicationService serviceImplementation="ai.kilocode.client.migration.KiloMigrationService"/>
|
||||
<applicationService serviceImplementation="ai.kilocode.client.autocomplete.KiloAutocompleteSettingsService"/>
|
||||
<customPasteProvider implementation="ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider"/>
|
||||
|
||||
<notificationGroup id="Kilo Code"
|
||||
displayType="BALLOON"
|
||||
@@ -20,6 +21,8 @@
|
||||
icon="/icons/kilo.svg"
|
||||
factoryClass="ai.kilocode.client.KiloToolWindowFactory"/>
|
||||
|
||||
<fileEditorProvider id="KiloVfsEditor" implementation="ai.kilocode.client.vfs.KiloFileEditorProvider"/>
|
||||
|
||||
<applicationConfigurable
|
||||
parentId="tools"
|
||||
id="ai.kilocode.jetbrains.settings"
|
||||
|
||||
@@ -20,6 +20,8 @@ feedback.dialog.discord=Join our Discord community
|
||||
feedback.dialog.support=Customer Support
|
||||
session.scroll.bottom=Scroll to bottom
|
||||
session.scroll.question=Scroll to question
|
||||
session.drop.files.title=Drop files here
|
||||
session.drop.files.subtitle=to add them to the prompt
|
||||
session.tab.new=New Session
|
||||
session.tab.untitled=Untitled Session
|
||||
|
||||
@@ -135,6 +137,21 @@ prompt.placeholder.with.newline=Type a message... ({0} for new line)
|
||||
prompt.button.send=Send
|
||||
prompt.button.stop=Stop
|
||||
prompt.button.send.tooltip.stop=To stop, press {0}
|
||||
prompt.attachment.remove=Remove {0}
|
||||
prompt.attachment.open=Open {0}
|
||||
prompt.attachment.tooltip=Name: {0}\nType: {1}\nLocation: {2}
|
||||
prompt.attachment.embedded=Embedded content
|
||||
prompt.attachment.unsupported.model=The selected model does not support image or PDF attachments.
|
||||
prompt.attachment.missing=Attachment no longer exists: {0}
|
||||
prompt.attachment.send.failed=Failed to send attachment: {0}
|
||||
session.attachment.title=Attachment
|
||||
session.attachment.path=Kilo / Attachments / {0} / {1}
|
||||
session.attachment.loading=Loading attachment...
|
||||
session.attachment.missing=Attachment not found
|
||||
session.attachment.unsupported=Cannot preview {0}
|
||||
session.attachment.mime=Type: {0}
|
||||
session.attachment.size=Size: {0} bytes
|
||||
session.attachment.error=Failed to load attachment: {0}
|
||||
prompt.action.enhance=Enhance prompt
|
||||
prompt.action.enhance.loading=Enhancing prompt...
|
||||
prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.
|
||||
|
||||
+61
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.ui.ConnectionPanel
|
||||
import ai.kilocode.client.session.ui.empty.EmptySessionPanel
|
||||
import ai.kilocode.client.session.ui.LoadingPanel
|
||||
import ai.kilocode.client.session.ui.SessionDropOverlay
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
@@ -56,6 +57,59 @@ class SessionUiLayoutTest : SessionUiTestBase() {
|
||||
assertEquals(listOf(connection, prompt), stack.components.toList())
|
||||
}
|
||||
|
||||
fun `test drop overlay is attached under root overlay layer`() {
|
||||
val root = find<SessionRootPanel>(ui)
|
||||
val drop = find<SessionDropOverlay>(ui)
|
||||
|
||||
assertSame(root.overlay, drop.parent)
|
||||
assertTrue(drop.isVisible)
|
||||
assertFalse(drop.contains(1, 1))
|
||||
assertFalse(root.blocker.components.contains(drop))
|
||||
}
|
||||
|
||||
fun `test drop overlay is visual only and not native file drop target`() {
|
||||
val drop = find<SessionDropOverlay>(ui)
|
||||
|
||||
assertNull(drop.dropTarget)
|
||||
}
|
||||
|
||||
fun `test prompt file drag leave does not immediately hide drop overlay`() {
|
||||
val prompt = find<PromptPanel>(ui)
|
||||
val drop = find<SessionDropOverlay>(ui)
|
||||
val card = dropCard(drop)
|
||||
|
||||
layout()
|
||||
prompt.onFileDrag(true)
|
||||
assertFalse(drop.contains(1, 1))
|
||||
assertTrue(card.isVisible)
|
||||
|
||||
prompt.onFileDrag(false)
|
||||
assertFalse(drop.contains(1, 1))
|
||||
assertTrue(card.isVisible)
|
||||
|
||||
prompt.onFileDrag(true)
|
||||
drop.setActive(false)
|
||||
}
|
||||
|
||||
fun `test drop overlay covers full session after layout`() {
|
||||
val root = find<SessionRootPanel>(ui)
|
||||
val drop = find<SessionDropOverlay>(ui)
|
||||
|
||||
layout()
|
||||
|
||||
assertEquals(java.awt.Rectangle(0, 0, root.overlay.width, root.overlay.height), drop.bounds)
|
||||
}
|
||||
|
||||
fun `test drop overlay is above account and scroll overlays`() {
|
||||
val root = find<SessionRootPanel>(ui)
|
||||
val drop = find<SessionDropOverlay>(ui)
|
||||
val account = find<SessionAccountOverlay>(ui)
|
||||
val jump = jumpButton()
|
||||
|
||||
assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(account))
|
||||
assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(jump))
|
||||
}
|
||||
|
||||
fun `test active views are children of message list panel`() {
|
||||
ui = newUi(id = "ses_test")
|
||||
settle()
|
||||
@@ -465,4 +519,11 @@ class SessionUiLayoutTest : SessionUiTestBase() {
|
||||
assertEquals(top, overlay.y)
|
||||
assertEquals(root.overlay.width - overlay.width - right, overlay.x)
|
||||
}
|
||||
|
||||
private fun dropCard(drop: SessionDropOverlay) = drop.components
|
||||
.single()
|
||||
.let { it as javax.swing.JComponent }
|
||||
.components
|
||||
.single()
|
||||
.let { it as javax.swing.JComponent }
|
||||
}
|
||||
|
||||
+61
@@ -133,6 +133,27 @@ class SessionModelTest : BasePlatformTestCase() {
|
||||
assertTrue(events.single() is SessionModelEvent.ContentUpdated)
|
||||
}
|
||||
|
||||
fun `test updateContent ignores new empty text content`() {
|
||||
model.addMessage(msg("m1", "user"))
|
||||
events.clear()
|
||||
|
||||
model.updateContent("m1", part("p1", "m1", "text", text = " "))
|
||||
|
||||
assertNull(model.message("m1")!!.parts["p1"])
|
||||
assertTrue(events.isEmpty())
|
||||
}
|
||||
|
||||
fun `test updateContent removes existing text when it becomes empty`() {
|
||||
model.addMessage(msg("m1", "user"))
|
||||
model.updateContent("m1", part("p1", "m1", "text", text = "visible"))
|
||||
events.clear()
|
||||
|
||||
model.updateContent("m1", part("p1", "m1", "text", text = ""))
|
||||
|
||||
assertNull(model.message("m1")!!.parts["p1"])
|
||||
assertEquals("ContentRemoved m1/p1", events.single().toString())
|
||||
}
|
||||
|
||||
fun `test updateContent reasoning creates Reasoning content`() {
|
||||
model.addMessage(msg("m1", "assistant"))
|
||||
|
||||
@@ -153,6 +174,31 @@ class SessionModelTest : BasePlatformTestCase() {
|
||||
assertTrue(p.done)
|
||||
}
|
||||
|
||||
fun `test updateContent file creates attachment content and updates metadata`() {
|
||||
model.addMessage(msg("m1", "user"))
|
||||
events.clear()
|
||||
|
||||
model.updateContent("m1", filePart("f1", "m1", "image/png", "file:///tmp/a.png", "a.png"))
|
||||
|
||||
val file = model.message("m1")!!.parts["f1"] as FileAttachment
|
||||
assertEquals("image/png", file.mime)
|
||||
assertEquals("file:///tmp/a.png", file.url)
|
||||
assertEquals("a.png", file.filename)
|
||||
assertEquals("ContentAdded m1/f1", events.single().toString())
|
||||
|
||||
model.updateContent("m1", filePart("f1", "m1", "application/pdf", "file:///tmp/b.pdf", "b.pdf"))
|
||||
|
||||
assertSame(file, model.message("m1")!!.parts["f1"])
|
||||
assertEquals("application/pdf", file.mime)
|
||||
assertEquals("file:///tmp/b.pdf", file.url)
|
||||
assertEquals("b.pdf", file.filename)
|
||||
assertTrue(events.any { it.toString() == "ContentUpdated m1/f1" })
|
||||
assertModel("""
|
||||
user#m1
|
||||
file#f1 application/pdf b.pdf
|
||||
""")
|
||||
}
|
||||
|
||||
fun `test updateContent tool creates Tool content and tracks state`() {
|
||||
model.addMessage(msg("m1", "assistant"))
|
||||
|
||||
@@ -848,12 +894,18 @@ class SessionModelTest : BasePlatformTestCase() {
|
||||
tokens: TokensDto? = null,
|
||||
todos: List<TodoDto> = emptyList(),
|
||||
todoView: TodoViewDto? = null,
|
||||
mime: String? = null,
|
||||
url: String? = null,
|
||||
filename: String? = null,
|
||||
) = PartDto(
|
||||
id = id,
|
||||
sessionID = "ses",
|
||||
messageID = mid,
|
||||
type = type,
|
||||
text = text,
|
||||
mime = mime,
|
||||
url = url,
|
||||
filename = filename,
|
||||
tool = tool,
|
||||
state = state,
|
||||
title = title,
|
||||
@@ -869,6 +921,15 @@ class SessionModelTest : BasePlatformTestCase() {
|
||||
tokens = tokens,
|
||||
)
|
||||
|
||||
private fun filePart(id: String, mid: String, mime: String, url: String, filename: String) = part(
|
||||
id = id,
|
||||
mid = mid,
|
||||
type = "file",
|
||||
mime = mime,
|
||||
url = url,
|
||||
filename = filename,
|
||||
)
|
||||
|
||||
private fun question(id: String) = Question(
|
||||
id = id,
|
||||
items = listOf(
|
||||
|
||||
+357
-20
@@ -2,22 +2,49 @@ package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
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.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 com.intellij.icons.AllIcons
|
||||
import com.intellij.notification.Notification
|
||||
import com.intellij.notification.Notifications
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.actionSystem.DataSink
|
||||
import com.intellij.openapi.actionSystem.UiDataProvider
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.editor.actions.PasteAction
|
||||
import com.intellij.openapi.keymap.KeymapUtil
|
||||
import com.intellij.testFramework.PlatformTestUtil
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.EditorTextField
|
||||
import com.intellij.ui.components.JBLabel
|
||||
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.CancellationException
|
||||
import java.awt.Container
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JButton
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
@@ -25,7 +52,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test prompt input uses editor font settings`() {
|
||||
val style = SessionEditorStyle.current()
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val font = panel.inputFont()
|
||||
|
||||
assertEquals(style.editorFamily, font.name)
|
||||
@@ -34,13 +61,13 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test prompt input uses editor background`() {
|
||||
val style = SessionEditorStyle.current()
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background)
|
||||
}
|
||||
|
||||
fun `test applyStyle updates prompt input and height`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val style = SessionEditorStyle.create(family = "Courier New", size = 26)
|
||||
|
||||
panel.applyStyle(style)
|
||||
@@ -51,7 +78,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test prompt editor grows when lines are added`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val editor = panel.defaultFocusedComponent as EditorTextField
|
||||
val min = editor.preferredSize.height
|
||||
|
||||
@@ -61,7 +88,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test prompt editor shrinks when lines are removed`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val editor = panel.defaultFocusedComponent as EditorTextField
|
||||
val min = editor.preferredSize.height
|
||||
|
||||
@@ -74,7 +101,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test prompt editor shrinks after clear`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val editor = panel.defaultFocusedComponent as EditorTextField
|
||||
val min = editor.preferredSize.height
|
||||
|
||||
@@ -86,8 +113,124 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertEquals(min, editor.preferredSize.height)
|
||||
}
|
||||
|
||||
fun `test attachment only prompt can send`() {
|
||||
var sent = false
|
||||
val panel = PromptPanel(project, { text, files ->
|
||||
sent = text.isBlank() && files.single().url == "file:///tmp/a.png"
|
||||
}, {}, { _, _ -> })
|
||||
panel.setReady(true)
|
||||
|
||||
panel.addAttachmentForTest(PromptAttachment("a", "a.png", "image/png", "file:///tmp/a.png"))
|
||||
panel.send()
|
||||
waitForSend { sent }
|
||||
|
||||
assertTrue(sent)
|
||||
}
|
||||
|
||||
fun `test clear removes attachments`() {
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
|
||||
panel.addAttachmentForTest(PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt"))
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
|
||||
panel.clear()
|
||||
|
||||
assertEquals(0, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test removed attachment can be added again`() {
|
||||
val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt")
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
|
||||
panel.addAttachmentForTest(item)
|
||||
attachmentRemoveButton(panel, item).doClick()
|
||||
panel.addAttachmentForTest(item)
|
||||
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test attachment card is compact icon only with tooltip metadata and hover remove`() {
|
||||
val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a%20b.txt")
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
|
||||
panel.addAttachmentForTest(item)
|
||||
|
||||
val button = attachmentRemoveButton(panel, item)
|
||||
val card = attachmentCard(panel)
|
||||
|
||||
assertFalse(button.isVisible)
|
||||
assertTrue(card.toolTipText.contains("a.txt"))
|
||||
assertTrue(card.toolTipText.contains("text/plain"))
|
||||
assertTrue(card.toolTipText.contains("/tmp/a b.txt"))
|
||||
assertFalse(card.toolTipText.contains("file:///"))
|
||||
assertTrue(card.toolTipText.startsWith("<html>"))
|
||||
assertTrue(card.toolTipText.contains("Name: a.txt<br>Type: text/plain<br>Location: /tmp/a b.txt"))
|
||||
assertFalse(labels(card).any { it.text == "a.txt" || it.text == "text/plain" || it.text == "/tmp/a b.txt" })
|
||||
assertTrue(components(card).filterIsInstance<javax.swing.JComponent>().any { it !== button && it.toolTipText == card.toolTipText })
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_WIDTH), card.preferredSize.width)
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT), card.preferredSize.height)
|
||||
assertEquals(0, card.getComponentZOrder(button))
|
||||
|
||||
val label = labels(card).first()
|
||||
label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false))
|
||||
|
||||
assertTrue(button.isVisible)
|
||||
val icon = button.icon
|
||||
button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false))
|
||||
assertNotSame(icon, button.icon)
|
||||
button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, 1, 1, 0, false))
|
||||
assertSame(icon, button.icon)
|
||||
}
|
||||
|
||||
fun `test attachment tooltip hides embedded binary content`() {
|
||||
val item = PromptAttachment("a", "a.png", "image/png", "data:image/png;base64,aGVsbG8=")
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
|
||||
panel.addAttachmentForTest(item)
|
||||
|
||||
val tip = attachmentCard(panel).toolTipText
|
||||
|
||||
assertTrue(tip.contains("Name: a.png"))
|
||||
assertTrue(tip.contains("Type: image/png"))
|
||||
assertTrue(tip.contains("Location: ${KiloBundle.message("prompt.attachment.embedded")}"))
|
||||
assertFalse(tip.contains("data:image/png"))
|
||||
assertFalse(tip.contains("base64"))
|
||||
assertFalse(tip.contains("aGVsbG8="))
|
||||
}
|
||||
|
||||
fun `test attachment child click opens item`() {
|
||||
var opened = false
|
||||
val card = AttachmentCard(
|
||||
AttachmentCardItem("a.txt", "text/plain", "file:///tmp/a.txt"),
|
||||
open = { opened = true },
|
||||
)
|
||||
|
||||
val label = labels(card).first()
|
||||
label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
|
||||
|
||||
assertTrue(opened)
|
||||
}
|
||||
|
||||
fun `test attachment card previews embedded image data`() {
|
||||
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
|
||||
val out = ByteArrayOutputStream()
|
||||
ImageIO.write(image, "png", out)
|
||||
val card = AttachmentCard(
|
||||
AttachmentCardItem("a.png", "image/png", "data:image/png;base64,${Base64.getEncoder().encodeToString(out.toByteArray())}"),
|
||||
)
|
||||
|
||||
card.addNotify()
|
||||
repeat(20) {
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
if (labels(card).any { it.icon is ImageIcon }) return@repeat
|
||||
Thread.sleep(20)
|
||||
}
|
||||
|
||||
assertTrue(labels(card).any { it.icon is ImageIcon })
|
||||
}
|
||||
|
||||
fun `test reasoning picker hides when variants are empty`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
panel.reasoning.setItems(emptyList())
|
||||
|
||||
@@ -95,7 +238,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test reasoning picker shows selected variant`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
panel.reasoning.setItems(listOf(ReasoningPicker.Item("low", "Low"), ReasoningPicker.Item("high", "High")), "high")
|
||||
|
||||
@@ -119,7 +262,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test reset visibility can be toggled`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
panel.setResetVisible(true)
|
||||
|
||||
@@ -127,7 +270,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test prompt editor exposes send context`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val sink = TestSink()
|
||||
|
||||
(panel.defaultFocusedComponent as UiDataProvider).uiDataSnapshot(sink)
|
||||
@@ -136,7 +279,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test prompt button exposes send context`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val sink = TestSink()
|
||||
|
||||
(panel.buttonForTest() as UiDataProvider).uiDataSnapshot(sink)
|
||||
@@ -144,8 +287,106 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
assertSame(panel, sink.send)
|
||||
}
|
||||
|
||||
fun `test prompt paste provider invokes registered handler`() {
|
||||
val editor = createEditor()
|
||||
val item = FileListTransferable(listOf(File.createTempFile("kilo-paste", ".txt")))
|
||||
var seen: Transferable? = null
|
||||
editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { seen = it })
|
||||
|
||||
try {
|
||||
PromptAttachmentPasteProvider().performPaste(pasteContext(editor, item))
|
||||
|
||||
assertSame(item, seen)
|
||||
} finally {
|
||||
EditorFactory.getInstance().releaseEditor(editor)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test file list paste adds attachment`() {
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
val file = File.createTempFile("kilo-paste", ".txt")
|
||||
file.writeText("hello")
|
||||
|
||||
PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file))))
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test frontend file attachment defers data url encoding until send`() {
|
||||
val file = File.createTempFile("kilo-paste", ".txt")
|
||||
file.writeText("hello")
|
||||
|
||||
val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single()
|
||||
|
||||
assertTrue(item.url.startsWith("file://"))
|
||||
assertTrue(item.part().url.orEmpty().startsWith("data:text/plain;base64,"))
|
||||
}
|
||||
|
||||
fun `test pasted frontend file sends data url payload`() {
|
||||
var sent: ai.kilocode.rpc.dto.PromptPartDto? = null
|
||||
val panel = PromptPanel(project, { _, files -> sent = files.single() }, {}, { _, _ -> })
|
||||
val file = File.createTempFile("kilo-paste", ".txt")
|
||||
file.writeText("hello")
|
||||
panel.setReady(true)
|
||||
|
||||
PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file))))
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
panel.send()
|
||||
waitForSend { sent != null }
|
||||
|
||||
val item = sent!!
|
||||
assertEquals("text/plain", item.mime)
|
||||
assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,"))
|
||||
assertFalse(item.url.orEmpty().startsWith("file://"))
|
||||
}
|
||||
|
||||
fun `test raw image paste adds attachment`() {
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
|
||||
|
||||
PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(image)))
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test file paste ignores companion image flavor`() {
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
val file = File.createTempFile("kilo-paste", ".png")
|
||||
file.writeBytes(byteArrayOf())
|
||||
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
|
||||
|
||||
PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileImageTransferable(listOf(file), image)))
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(1, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test normal text paste is not intercepted`() {
|
||||
val editor = createEditor()
|
||||
val provider = PromptAttachmentPasteProvider()
|
||||
editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler {})
|
||||
|
||||
try {
|
||||
assertFalse(provider.isPasteEnabled(pasteContext(editor, StringSelection("hello"))))
|
||||
} finally {
|
||||
EditorFactory.getInstance().releaseEditor(editor)
|
||||
}
|
||||
}
|
||||
|
||||
fun `test disabled media model blocks pasted image`() {
|
||||
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
|
||||
panel.setAttachmentEnabled(false)
|
||||
|
||||
PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB))))
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
|
||||
assertEquals(0, panel.attachmentCountForTest())
|
||||
}
|
||||
|
||||
fun `test prompt button switches between send and stop state`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
|
||||
assertEquals(KeymapUtil.createTooltipText("Send", "Kilo.SendPrompt"), panel.buttonForTest().toolTipText)
|
||||
assertFalse(panel.isStopEnabled)
|
||||
@@ -157,7 +398,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test auto approve button toggles and updates tooltip`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val button = autoApproveButton(panel)
|
||||
var seen: Boolean? = null
|
||||
panel.onAutoApproveToggle = { seen = it }
|
||||
@@ -188,7 +429,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test auto approve and enhance buttons sit next to send button`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val auto = autoApproveButton(panel)
|
||||
val enhance = enhanceButton(panel)
|
||||
val send = panel.buttonForTest()
|
||||
@@ -202,7 +443,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test enhance button follows connection and busy state`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val enhance = enhanceButton(panel)
|
||||
|
||||
assertFalse(enhance.isEnabled)
|
||||
@@ -220,7 +461,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
fun `test enhance button rewrites active draft`() {
|
||||
var seen: String? = null
|
||||
var complete: ((Result<String>) -> Unit)? = null
|
||||
val panel = PromptPanel(project, {}, {}, { text, done ->
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { text, done ->
|
||||
seen = text
|
||||
complete = done
|
||||
})
|
||||
@@ -249,7 +490,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test edit while enhancing ignores stale completion`() {
|
||||
var complete: ((Result<String>) -> Unit)? = null
|
||||
val panel = PromptPanel(project, {}, {}, { _, done -> complete = done })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done })
|
||||
val editor = panel.defaultFocusedComponent as EditorTextField
|
||||
val enhance = enhanceButton(panel)
|
||||
panel.setReady(true)
|
||||
@@ -273,7 +514,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener)
|
||||
project.messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener)
|
||||
var complete: ((Result<String>) -> Unit)? = null
|
||||
val panel = PromptPanel(project, {}, {}, { _, done -> complete = done })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done })
|
||||
val editor = panel.defaultFocusedComponent as EditorTextField
|
||||
val enhance = enhanceButton(panel)
|
||||
panel.setReady(true)
|
||||
@@ -290,7 +531,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
|
||||
fun `test empty enhancement inserts explanation without request`() {
|
||||
var requests = 0
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> requests++ })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> requests++ })
|
||||
val editor = panel.defaultFocusedComponent as EditorTextField
|
||||
panel.setReady(true)
|
||||
|
||||
@@ -301,7 +542,7 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
|
||||
fun `test pickers belong to rounded shell`() {
|
||||
val panel = PromptPanel(project, {}, {}, { _, _ -> })
|
||||
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
|
||||
val shell = panel.shellForTest()
|
||||
|
||||
assertTrue(SwingUtilities.isDescendingFrom(panel.mode, shell))
|
||||
@@ -319,6 +560,25 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachmentRemoveButton(panel: PromptPanel, item: PromptAttachment): JButton {
|
||||
val name = KiloBundle.message("prompt.attachment.remove", item.name)
|
||||
return buttons(panel).first { it.accessibleContext.accessibleName == name }
|
||||
}
|
||||
|
||||
private fun attachmentCard(root: java.awt.Component): AttachmentCard {
|
||||
fun visit(node: java.awt.Component): AttachmentCard? {
|
||||
if (node is AttachmentCard) return node
|
||||
if (node is Container) {
|
||||
for (child in node.components) {
|
||||
val card = visit(child)
|
||||
if (card != null) return card
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return visit(root)!!
|
||||
}
|
||||
|
||||
private fun enhanceButton(panel: PromptPanel): JButton {
|
||||
val name = KiloBundle.message("prompt.action.enhance")
|
||||
return buttons(panel).first { it.accessibleContext.accessibleName == name }
|
||||
@@ -334,6 +594,83 @@ class PromptPanelTest : BasePlatformTestCase() {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun labels(root: java.awt.Component): List<JBLabel> {
|
||||
return components(root).filterIsInstance<JBLabel>()
|
||||
}
|
||||
|
||||
private fun components(root: java.awt.Component): List<java.awt.Component> {
|
||||
val out = mutableListOf<java.awt.Component>()
|
||||
fun visit(node: java.awt.Component) {
|
||||
out.add(node)
|
||||
if (node is Container) node.components.forEach(::visit)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun createEditor(): Editor {
|
||||
val factory = EditorFactory.getInstance()
|
||||
return factory.createEditor(factory.createDocument(""), project)
|
||||
}
|
||||
|
||||
private fun waitForSend(done: () -> Boolean) {
|
||||
repeat(50) {
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
if (done()) return
|
||||
Thread.sleep(20)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pasteContext(editor: Editor, item: Transferable) = DataContext { id ->
|
||||
when (id) {
|
||||
CommonDataKeys.EDITOR.name -> editor
|
||||
PasteAction.TRANSFERABLE_PROVIDER.name -> Producer { item }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private class FileListTransferable(private val files: List<File>) : Transferable {
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.javaFileListFlavor)
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.javaFileListFlavor
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor): Any {
|
||||
if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor)
|
||||
return files
|
||||
}
|
||||
}
|
||||
|
||||
private class ImageTransferable(private val image: BufferedImage) : Transferable {
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor): Any {
|
||||
if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor)
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
private class FileImageTransferable(
|
||||
private val files: List<File>,
|
||||
private val image: BufferedImage,
|
||||
) : Transferable {
|
||||
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(
|
||||
DataFlavor.javaFileListFlavor,
|
||||
DataFlavor.imageFlavor,
|
||||
)
|
||||
|
||||
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
|
||||
return flavor == DataFlavor.javaFileListFlavor || flavor == DataFlavor.imageFlavor
|
||||
}
|
||||
|
||||
override fun getTransferData(flavor: DataFlavor): Any {
|
||||
if (flavor == DataFlavor.javaFileListFlavor) return files
|
||||
if (flavor == DataFlavor.imageFlavor) return image
|
||||
throw java.awt.datatransfer.UnsupportedFlavorException(flavor)
|
||||
}
|
||||
}
|
||||
|
||||
private class TestSink : DataSink {
|
||||
var send: Any? = null
|
||||
|
||||
|
||||
+74
@@ -1,7 +1,10 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.Dimension
|
||||
import java.awt.Rectangle
|
||||
@@ -71,6 +74,63 @@ class SessionRootPanelTest : BasePlatformTestCase() {
|
||||
assertTrue(child.laid)
|
||||
}
|
||||
|
||||
fun `test drop overlay starts visible but never captures hit tests`() {
|
||||
val drop = SessionDropOverlay().apply {
|
||||
setSize(200, 100)
|
||||
}
|
||||
val card = dropCard(drop)
|
||||
|
||||
assertTrue(drop.isVisible)
|
||||
assertFalse(drop.contains(50, 50))
|
||||
assertFalse(card.isVisible)
|
||||
|
||||
drop.setActive(true)
|
||||
assertFalse(drop.contains(50, 50))
|
||||
assertTrue(card.isVisible)
|
||||
|
||||
drop.setActive(false)
|
||||
assertFalse(drop.contains(50, 50))
|
||||
assertFalse(card.isVisible)
|
||||
}
|
||||
|
||||
fun `test drop overlay can fill root overlay bounds`() {
|
||||
val root = SessionRootPanel().apply {
|
||||
setSize(400, 260)
|
||||
}
|
||||
val drop = SessionDropOverlay()
|
||||
|
||||
root.addOverlay(drop) { pane, _ ->
|
||||
Rectangle(0, 0, pane.width, pane.height)
|
||||
}
|
||||
root.doLayout()
|
||||
|
||||
assertEquals(Rectangle(0, 0, 400, 260), drop.bounds)
|
||||
}
|
||||
|
||||
fun `test drop overlay labels use platform heading fonts`() {
|
||||
val drop = SessionDropOverlay()
|
||||
val labels = dropLabels(drop)
|
||||
|
||||
assertEquals("Drop files here", labels[0].text)
|
||||
assertEquals(JBFont.h0(), labels[0].font)
|
||||
assertEquals("to add them to the prompt", labels[1].text)
|
||||
assertEquals(JBFont.h2(), labels[1].font)
|
||||
assertEquals(AllIcons.Actions.Download.iconWidth * 3, labels[2].icon.iconWidth)
|
||||
assertEquals(AllIcons.Actions.Download.iconHeight * 3, labels[2].icon.iconHeight)
|
||||
}
|
||||
|
||||
fun `test drop overlay is registered in overlay layer not blocker`() {
|
||||
val root = SessionRootPanel()
|
||||
val drop = SessionDropOverlay()
|
||||
|
||||
root.addOverlay(drop) { pane, _ ->
|
||||
Rectangle(0, 0, pane.width, pane.height)
|
||||
}
|
||||
|
||||
assertSame(root.overlay, drop.parent)
|
||||
assertFalse(root.blocker.components.contains(drop))
|
||||
}
|
||||
|
||||
fun `test setBlocked makes blocker visible and setBlocked false hides it`() {
|
||||
val root = SessionRootPanel().apply { setSize(200, 100) }
|
||||
root.doLayout()
|
||||
@@ -135,4 +195,18 @@ class SessionRootPanelTest : BasePlatformTestCase() {
|
||||
super.doLayout()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropCard(drop: SessionDropOverlay) = drop.components
|
||||
.single()
|
||||
.let { it as javax.swing.JComponent }
|
||||
.components
|
||||
.single()
|
||||
.let { it as javax.swing.JComponent }
|
||||
|
||||
private fun dropLabels(drop: SessionDropOverlay): List<JBLabel> {
|
||||
val stack = dropCard(drop).components.single() as javax.swing.JComponent
|
||||
return stack.components
|
||||
.map { it as javax.swing.JComponent }
|
||||
.map { it.components.single() as JBLabel }
|
||||
}
|
||||
}
|
||||
|
||||
+259
@@ -2,7 +2,13 @@ package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCard
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.AttachmentView
|
||||
import ai.kilocode.client.session.views.PromptAttachmentView
|
||||
import ai.kilocode.client.session.views.tool.ReadToolView
|
||||
import ai.kilocode.client.session.views.TextView
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
import ai.kilocode.rpc.dto.MessageTimeDto
|
||||
import ai.kilocode.rpc.dto.MessageWithPartsDto
|
||||
@@ -10,6 +16,11 @@ import ai.kilocode.rpc.dto.PartDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Container
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
/**
|
||||
* Integration test: mutate [SessionModel] directly on the EDT and verify
|
||||
@@ -154,6 +165,223 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
|
||||
assertNull(gv.border)
|
||||
}
|
||||
|
||||
fun `test assistant file part renders as attachment view`() {
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
model.updateContent(
|
||||
"a1",
|
||||
PartDto(
|
||||
id = "f1",
|
||||
sessionID = "ses",
|
||||
messageID = "a1",
|
||||
type = "file",
|
||||
mime = "image/png",
|
||||
url = "file:///tmp/a.png",
|
||||
filename = "a.png",
|
||||
),
|
||||
)
|
||||
|
||||
val view = panel.findMessage("a1")!!.part("f1")
|
||||
assertTrue(view is AttachmentView)
|
||||
assertEquals("AttachmentView#f1:a.png", view!!.dumpLabel())
|
||||
assertNotNull(find(view, AttachmentCard::class.java))
|
||||
assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") })
|
||||
}
|
||||
|
||||
fun `test user file part renders as prompt attachment 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",
|
||||
),
|
||||
)
|
||||
|
||||
val view = panel.findMessage("u1")!!.part("f1")
|
||||
assertTrue(view is PromptAttachmentView)
|
||||
assertEquals("PromptAttachmentView#attachments:u1[f1]", view!!.dumpLabel())
|
||||
assertNotNull(find(view, AttachmentCard::class.java))
|
||||
assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") })
|
||||
}
|
||||
|
||||
fun `test user text and attachments share one prompt container`() {
|
||||
val opened = mutableListOf<String>()
|
||||
val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { _, it -> opened.add(it.url) })
|
||||
model.upsertMessage(msg("u1", "user"))
|
||||
model.updateContent("u1", part("p1", "u1", "text", text = "look at this"))
|
||||
model.updateContent(
|
||||
"u1",
|
||||
PartDto(
|
||||
id = "f1",
|
||||
sessionID = "ses",
|
||||
messageID = "u1",
|
||||
type = "file",
|
||||
mime = "image/png",
|
||||
url = "data:image/png;base64,aGVsbG8=",
|
||||
filename = "a.png",
|
||||
),
|
||||
)
|
||||
model.updateContent(
|
||||
"u1",
|
||||
PartDto(
|
||||
id = "f2",
|
||||
sessionID = "ses",
|
||||
messageID = "u1",
|
||||
type = "file",
|
||||
mime = "text/plain",
|
||||
url = "data:text/plain;base64,aGVsbG8=",
|
||||
filename = "note.txt",
|
||||
),
|
||||
)
|
||||
|
||||
val msg = item.findMessage("u1")!!
|
||||
val attachment = msg.part("f1")!!
|
||||
val other = msg.part("f2")!!
|
||||
|
||||
assertSame(msg, attachment.parent)
|
||||
assertSame(attachment, other)
|
||||
assertEquals(listOf("p1", "f1", "f2"), msg.partIds())
|
||||
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
|
||||
assertEquals(2, findAll(attachment, AttachmentCard::class.java).size)
|
||||
|
||||
val cards = findAll(attachment, AttachmentCard::class.java)
|
||||
for (card in cards) {
|
||||
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
|
||||
}
|
||||
|
||||
assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened)
|
||||
}
|
||||
|
||||
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"))
|
||||
model.updateContent("u1", part("p2", "u1", "text", text = " "))
|
||||
model.updateContent(
|
||||
"u1",
|
||||
PartDto(
|
||||
id = "f1",
|
||||
sessionID = "ses",
|
||||
messageID = "u1",
|
||||
type = "file",
|
||||
mime = "image/png",
|
||||
url = "data:image/png;base64,aGVsbG8=",
|
||||
filename = "a.png",
|
||||
),
|
||||
)
|
||||
|
||||
val msg = panel.findMessage("u1")!!
|
||||
|
||||
assertNull(msg.part("p2"))
|
||||
assertEquals(listOf("p1", "f1"), msg.partIds())
|
||||
assertEquals(1, msg.components.filterIsInstance<TextView>().size)
|
||||
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
|
||||
}
|
||||
|
||||
fun `test prompt text panel is removed when content becomes empty`() {
|
||||
model.upsertMessage(msg("u1", "user"))
|
||||
model.updateContent("u1", part("p1", "u1", "text", text = "visible"))
|
||||
|
||||
assertNotNull(panel.findMessage("u1")!!.part("p1"))
|
||||
|
||||
model.updateContent("u1", part("p1", "u1", "text", text = ""))
|
||||
|
||||
val msg = panel.findMessage("u1")!!
|
||||
assertNull(msg.part("p1"))
|
||||
assertTrue(msg.partIds().isEmpty())
|
||||
assertEquals(0, msg.components.filterIsInstance<TextView>().size)
|
||||
}
|
||||
|
||||
fun `test user attachment strip scrolls horizontally only`() {
|
||||
model.upsertMessage(msg("u1", "user"))
|
||||
for (i in 1..8) {
|
||||
model.updateContent(
|
||||
"u1",
|
||||
PartDto(
|
||||
id = "f$i",
|
||||
sessionID = "ses",
|
||||
messageID = "u1",
|
||||
type = "file",
|
||||
mime = "image/png",
|
||||
url = "file:///tmp/$i.png",
|
||||
filename = "$i.png",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val view = panel.findMessage("u1")!!.part("f1") as PromptAttachmentView
|
||||
val height = view.preferredSize.height
|
||||
val pane = view.scrollPane()
|
||||
|
||||
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy)
|
||||
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy)
|
||||
assertEquals(0, view.insets.top)
|
||||
assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), view.insets.bottom)
|
||||
assertEquals(
|
||||
JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) +
|
||||
pane.horizontalScrollBar.preferredSize.height +
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
height,
|
||||
)
|
||||
|
||||
model.updateContent(
|
||||
"u1",
|
||||
PartDto(
|
||||
id = "f9",
|
||||
sessionID = "ses",
|
||||
messageID = "u1",
|
||||
type = "file",
|
||||
mime = "image/png",
|
||||
url = "file:///tmp/9.png",
|
||||
filename = "9.png",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(height, view.preferredSize.height)
|
||||
assertEquals((1..9).map { "f$it" }, view.ids())
|
||||
}
|
||||
|
||||
fun `test user read tool payload is hidden but assistant read tool renders`() {
|
||||
model.upsertMessage(msg("u1", "user"))
|
||||
model.upsertMessage(msg("a1", "assistant"))
|
||||
model.updateContent("u1", toolPart("ut1", "u1", "read", "completed"))
|
||||
model.updateContent("a1", toolPart("at1", "a1", "read", "completed"))
|
||||
|
||||
val user = panel.findMessage("u1")!!
|
||||
val assistant = panel.findMessage("a1")!!
|
||||
|
||||
assertTrue(user.partIds().isEmpty())
|
||||
assertNull(user.part("ut1"))
|
||||
assertTrue(assistant.part("at1") is ReadToolView)
|
||||
}
|
||||
|
||||
fun `test transcript attachment click delegates to attachment opener`() {
|
||||
val opened = mutableListOf<Pair<String, String>>()
|
||||
val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { msg, it -> opened.add(msg to it.url) })
|
||||
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 card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!!
|
||||
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
|
||||
|
||||
assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened)
|
||||
}
|
||||
|
||||
// ------ silent part types ------
|
||||
|
||||
fun `test step markers are not rendered in panel`() {
|
||||
@@ -225,4 +453,35 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
|
||||
private fun toolPart(id: String, mid: String, tool: String, state: String) = PartDto(
|
||||
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, state = state,
|
||||
)
|
||||
|
||||
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) {
|
||||
for (child in root.components) {
|
||||
val found = find(child, type)
|
||||
if (found != null) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun <T : Any> findAll(root: java.awt.Component, type: Class<T>): List<T> {
|
||||
val out = mutableListOf<T>()
|
||||
fun visit(node: java.awt.Component) {
|
||||
if (type.isInstance(node)) out.add(type.cast(node))
|
||||
if (node is Container) node.components.forEach(::visit)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buttons(root: java.awt.Component): List<JButton> {
|
||||
val out = mutableListOf<JButton>()
|
||||
fun visit(node: java.awt.Component) {
|
||||
if (node is JButton) out.add(node)
|
||||
if (node is Container) node.components.forEach(::visit)
|
||||
}
|
||||
visit(root)
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.testing.FakeAppRpcApi
|
||||
import ai.kilocode.client.testing.FakeSessionRpcApi
|
||||
import ai.kilocode.client.vfs.KiloPath
|
||||
import ai.kilocode.client.vfs.KiloVirtualFile
|
||||
import ai.kilocode.client.vfs.KiloVirtualFileSystem
|
||||
import ai.kilocode.rpc.dto.KiloAppStateDto
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
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 com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.vfs.VirtualFileManager
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.testFramework.replaceService
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class AttachmentEditorKindTest : BasePlatformTestCase() {
|
||||
fun testAttachmentParamsUseStableIdentityFields() {
|
||||
val item = FileAttachment("part1").apply {
|
||||
mime = "text/plain"
|
||||
url = "data:text/plain;base64,aGVsbG8="
|
||||
filename = "note.txt"
|
||||
}
|
||||
|
||||
val params = attachmentParams("ses1", "msg1", item, "note.txt", "/repo")
|
||||
val path = KiloPath(AttachmentEditorKind.ID, params).canonical()
|
||||
val json = KiloVirtualFileSystem.getInstance().getPath(path)
|
||||
val decoded = KiloVirtualFileSystem.decode(json)
|
||||
|
||||
assertEquals(path, decoded)
|
||||
assertEquals(AttachmentEditorKind.ID, path.kind)
|
||||
assertEquals("ses1", params["sessionId"])
|
||||
assertEquals("msg1", params["messageId"])
|
||||
assertEquals("part1", params["partId"])
|
||||
assertFalse(params["attachmentKey"].isNullOrBlank())
|
||||
assertEquals("note.txt", params["filename"])
|
||||
assertEquals("text/plain", params["mime"])
|
||||
assertEquals("/repo", params["directory"])
|
||||
assertFalse(json.contains("projectHash", ignoreCase = true))
|
||||
assertFalse(json.contains("launch", ignoreCase = true))
|
||||
assertFalse(json.contains("time", ignoreCase = true))
|
||||
assertFalse(json.contains("random", ignoreCase = true))
|
||||
}
|
||||
|
||||
fun testSameParamsMapToSameVirtualPath() {
|
||||
val params = linkedMapOf(
|
||||
"sessionId" to "ses1",
|
||||
"messageId" to "msg1",
|
||||
"partId" to "part1",
|
||||
"attachmentKey" to "key1",
|
||||
"filename" to "note.txt",
|
||||
"mime" to "text/plain",
|
||||
"directory" to "/repo",
|
||||
)
|
||||
|
||||
val one = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params))
|
||||
val two = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params.toList().reversed().toMap()))
|
||||
|
||||
assertEquals(one, two)
|
||||
assertFalse(one.contains("/system/kilo/editors"))
|
||||
assertFalse(one.contains("kiloattachment"))
|
||||
}
|
||||
|
||||
fun testDuplicatePartAttachmentsMapToDistinctVirtualFiles() {
|
||||
val first = FileAttachment("part1").apply {
|
||||
mime = "text/plain"
|
||||
url = "data:text/plain;base64,b25l"
|
||||
filename = "note.txt"
|
||||
}
|
||||
val second = FileAttachment("part1").apply {
|
||||
mime = "text/plain"
|
||||
url = "data:text/plain;base64,dHdv"
|
||||
filename = "note.txt"
|
||||
}
|
||||
val one = attachmentParams("ses1", "msg1", first, "note.txt", "/repo")
|
||||
val two = attachmentParams("ses1", "msg1", second, "note.txt", "/repo")
|
||||
|
||||
assertFalse(one == two)
|
||||
assertFalse(one["attachmentKey"] == two["attachmentKey"])
|
||||
assertFalse(KiloPath(AttachmentEditorKind.ID, one) == KiloPath(AttachmentEditorKind.ID, two))
|
||||
}
|
||||
|
||||
fun testVirtualFilesAreExcludedFromEditorHistory() {
|
||||
ensureAttachmentEditorKind()
|
||||
val file = KiloVirtualFile(KiloPath(AttachmentEditorKind.ID, mapOf(
|
||||
"directory" to "/repo",
|
||||
"sessionId" to "ses1",
|
||||
"messageId" to "msg1",
|
||||
"partId" to "part1",
|
||||
"filename" to "note.txt",
|
||||
)))
|
||||
|
||||
assertNull(VirtualFileManager.getInstance().findFileByUrl(file.url))
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
fun testFetchUsesAttachmentKeyBeforeDuplicatePartId() {
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
val app = FakeAppRpcApi()
|
||||
val rpc = FakeSessionRpcApi()
|
||||
app.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
val first = PartDto(
|
||||
id = "part1",
|
||||
sessionID = "ses1",
|
||||
messageID = "msg1",
|
||||
type = "file",
|
||||
mime = "text/plain",
|
||||
url = "data:text/plain;base64,b25l",
|
||||
filename = "note.txt",
|
||||
)
|
||||
val second = first.copy(url = "data:text/plain;base64,dHdv")
|
||||
rpc.history.add(MessageWithPartsDto(
|
||||
info = MessageDto(
|
||||
id = "msg1",
|
||||
sessionID = "ses1",
|
||||
role = "user",
|
||||
time = MessageTimeDto(created = 0.0),
|
||||
),
|
||||
parts = listOf(first, second),
|
||||
))
|
||||
ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable)
|
||||
project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable)
|
||||
val item = FileAttachment("part1").apply {
|
||||
mime = "text/plain"
|
||||
url = second.url.orEmpty()
|
||||
filename = "note.txt"
|
||||
}
|
||||
val results = mutableListOf<AttachmentData>()
|
||||
val parent = Disposer.newDisposable()
|
||||
|
||||
try {
|
||||
KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) {
|
||||
results.add(it)
|
||||
}
|
||||
|
||||
waitFor { results.any { it is AttachmentData.Text } }
|
||||
assertTrue(results.any { it is AttachmentData.Connecting })
|
||||
val data = results.last { it is AttachmentData.Text } as AttachmentData.Text
|
||||
assertEquals("two", data.text)
|
||||
assertEquals(1, rpc.attachmentParts.size)
|
||||
assertEquals("msg1", rpc.attachmentParts.single().messageId)
|
||||
assertEquals(0, rpc.historyCalls)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
cs.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
fun testLoadShowsConnectionFailedUntilRetryBecomesReady() = runBlocking {
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
val app = FakeAppRpcApi()
|
||||
val rpc = FakeSessionRpcApi()
|
||||
val part = PartDto(
|
||||
id = "part1",
|
||||
sessionID = "ses1",
|
||||
messageID = "msg1",
|
||||
type = "file",
|
||||
mime = "text/plain",
|
||||
url = "data:text/plain;base64,b2s=",
|
||||
filename = "note.txt",
|
||||
)
|
||||
rpc.history.add(MessageWithPartsDto(
|
||||
info = MessageDto(
|
||||
id = "msg1",
|
||||
sessionID = "ses1",
|
||||
role = "user",
|
||||
time = MessageTimeDto(created = 0.0),
|
||||
),
|
||||
parts = listOf(part),
|
||||
))
|
||||
app.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR)
|
||||
ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable)
|
||||
project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable)
|
||||
val item = FileAttachment("part1").apply {
|
||||
mime = part.mime.orEmpty()
|
||||
url = part.url.orEmpty()
|
||||
filename = part.filename.orEmpty()
|
||||
}
|
||||
val results = mutableListOf<AttachmentData>()
|
||||
val parent = Disposer.newDisposable()
|
||||
|
||||
try {
|
||||
KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) {
|
||||
results.add(it)
|
||||
}
|
||||
|
||||
waitFor { results.any { it is AttachmentData.ConnectionFailed } }
|
||||
assertTrue(results.any { it is AttachmentData.Connecting })
|
||||
assertTrue(results.any { it is AttachmentData.ConnectionFailed })
|
||||
|
||||
app.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
|
||||
|
||||
waitFor { results.any { it is AttachmentData.Text } }
|
||||
val data = results.last { it is AttachmentData.Text } as AttachmentData.Text
|
||||
assertEquals("ok", data.text)
|
||||
} finally {
|
||||
Disposer.dispose(parent)
|
||||
cs.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitFor(done: () -> Boolean) {
|
||||
val until = System.currentTimeMillis() + 5_000
|
||||
while (!done() && System.currentTimeMillis() < until) {
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
Thread.sleep(50)
|
||||
}
|
||||
assertTrue(done())
|
||||
}
|
||||
|
||||
private fun ref(session: String, message: String, item: FileAttachment, name: String, dir: String): AttachmentRef {
|
||||
val params = attachmentParams(session, message, item, name, dir)
|
||||
return AttachmentRef(
|
||||
directory = params.getValue("directory"),
|
||||
sessionId = params.getValue("sessionId"),
|
||||
messageId = params.getValue("messageId"),
|
||||
partId = params.getValue("partId"),
|
||||
attachmentKey = params["attachmentKey"],
|
||||
filename = params.getValue("filename"),
|
||||
mime = params.getValue("mime"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ai.kilocode.client.session.views.tool
|
||||
|
||||
import ai.kilocode.cli.KiloCliParser
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class KiloCliParserTest {
|
||||
@Test
|
||||
fun `tag extracts trimmed tool xml value`() {
|
||||
val text = """
|
||||
<path>
|
||||
/tmp/example.txt
|
||||
</path>
|
||||
<type>file</type>
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals("/tmp/example.txt", KiloCliParser.tag(text, "path"))
|
||||
assertEquals("file", KiloCliParser.tag(text, "type"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tag returns null for blank or missing value`() {
|
||||
assertNull(KiloCliParser.tag("<path> </path>", "path"))
|
||||
assertNull(KiloCliParser.tag("<type>file</type>", "path"))
|
||||
}
|
||||
}
|
||||
+26
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -46,6 +47,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
/** Message history returned by [messages]. */
|
||||
val history = mutableListOf<MessageWithPartsDto>()
|
||||
var historyGate: CompletableDeferred<Unit>? = null
|
||||
var historyCalls = 0
|
||||
private set
|
||||
|
||||
/** Recent sessions returned by [recent]. */
|
||||
val recent = mutableListOf<SessionDto>()
|
||||
@@ -82,6 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
var enhanceGate: CompletableDeferred<Unit>? = null
|
||||
var enhanceThrows: Exception? = null
|
||||
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
|
||||
val attachmentParts = mutableListOf<AttachmentCall>()
|
||||
val aborts = mutableListOf<Pair<String, String>>()
|
||||
val compacts = mutableListOf<Triple<String, String, ModelSelectionDto>>()
|
||||
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
|
||||
@@ -101,6 +105,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
private set
|
||||
|
||||
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?)
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
@@ -202,10 +207,25 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
|
||||
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> {
|
||||
assertNotEdt("messages")
|
||||
historyCalls++
|
||||
historyGate?.await()
|
||||
return history.toList()
|
||||
}
|
||||
|
||||
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? {
|
||||
assertNotEdt("attachmentPart")
|
||||
attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey))
|
||||
historyGate?.await()
|
||||
return history
|
||||
.firstOrNull { it.info.id == messageId }
|
||||
?.parts
|
||||
?.firstOrNull {
|
||||
if (it.type != "file") return@firstOrNull false
|
||||
if (!attachmentKey.isNullOrBlank()) key(it.id, it.filename.orEmpty(), it.url.orEmpty()) == attachmentKey
|
||||
else it.id == partId
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> {
|
||||
assertNotEdt("events")
|
||||
return eventFlow?.invoke(id, directory) ?: events
|
||||
@@ -245,4 +265,10 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
assertNotEdt("pendingQuestions")
|
||||
return pendingQuestionList.toList()
|
||||
}
|
||||
|
||||
private fun key(part: String, name: String, url: String): String {
|
||||
val value = listOf(part, name, url).joinToString("\u0000")
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return bytes.take(16).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package ai.kilocode.cli
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object KiloCliParser {
|
||||
private val tags = ConcurrentHashMap<String, Regex>()
|
||||
|
||||
fun tag(text: String, name: String): String? =
|
||||
tags.computeIfAbsent(name) {
|
||||
val tag = Regex.escape(it)
|
||||
Regex("<$tag>\\s*([\\s\\S]*?)\\s*</$tag>")
|
||||
}
|
||||
.find(text)
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -50,14 +50,23 @@ object ChatLogSummary {
|
||||
|
||||
fun prompt(prompt: PromptDto): String {
|
||||
val out = mutableListOf<String>()
|
||||
val text = prompt.parts.joinToString("\n") { it.text }
|
||||
val text = prompt.parts.mapNotNull { it.text }.joinToString("\n")
|
||||
val files = prompt.parts.filter { it.type == "file" }
|
||||
out += "kind=prompt"
|
||||
out += "parts=${prompt.parts.size}"
|
||||
out += "chars=${text.length}"
|
||||
if (files.isNotEmpty()) out += "attachments=${files.size}"
|
||||
files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }
|
||||
.takeIf { it > 0 }
|
||||
?.let { out += "media=$it" }
|
||||
prompt.parts.map { it.type }
|
||||
.distinct()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { out += "types=${it.joinToString(",")}" }
|
||||
files.mapNotNull { it.mime ?: it.type }
|
||||
.distinct()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { out += "attachmentTypes=${it.joinToString(",")}" }
|
||||
prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" }
|
||||
model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" }
|
||||
prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" }
|
||||
|
||||
@@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -86,6 +87,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
|
||||
/** Load message history for a session. */
|
||||
suspend fun messages(id: String, directory: String): List<MessageWithPartsDto>
|
||||
|
||||
/** Load one attachment part from a session without returning full history to the frontend. */
|
||||
suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto?
|
||||
|
||||
/** Subscribe to streaming chat events for a specific session. */
|
||||
suspend fun events(id: String, directory: String): Flow<ChatEventDto>
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ data class PartDto(
|
||||
val reason: String? = null,
|
||||
val cost: Double? = null,
|
||||
val tokens: TokensDto? = null,
|
||||
val mime: String? = null,
|
||||
val url: String? = null,
|
||||
val filename: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -96,7 +99,10 @@ data class PromptDto(
|
||||
@Serializable
|
||||
data class PromptPartDto(
|
||||
val type: String,
|
||||
val text: String,
|
||||
val text: String? = null,
|
||||
val mime: String? = null,
|
||||
val url: String? = null,
|
||||
val filename: String? = null,
|
||||
)
|
||||
|
||||
// --- Streaming Events ---
|
||||
|
||||
@@ -25,3 +25,6 @@ AGENTS.md
|
||||
|
||||
# Include bin/ directory for CLI binary (production)
|
||||
!bin/**
|
||||
|
||||
# Include WAV assets used by extension-host notification playback
|
||||
!audio-wav/**
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user