diff --git a/.changeset/jetbrains-hide-read-payloads.md b/.changeset/jetbrains-hide-read-payloads.md new file mode 100644 index 00000000000..6216844f0cd --- /dev/null +++ b/.changeset/jetbrains-hide-read-payloads.md @@ -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. diff --git a/.changeset/jetbrains-horizontal-attachments.md b/.changeset/jetbrains-horizontal-attachments.md new file mode 100644 index 00000000000..2b167a0198c --- /dev/null +++ b/.changeset/jetbrains-horizontal-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show JetBrains prompt attachments in one horizontal scrolling row in session history. diff --git a/.changeset/jetbrains-paste-attachments.md b/.changeset/jetbrains-paste-attachments.md new file mode 100644 index 00000000000..1003291b27a --- /dev/null +++ b/.changeset/jetbrains-paste-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support pasting files and images into JetBrains chat prompts as attachments. diff --git a/.changeset/jetbrains-prompt-attachment-preview.md b/.changeset/jetbrains-prompt-attachment-preview.md new file mode 100644 index 00000000000..f5b33305c46 --- /dev/null +++ b/.changeset/jetbrains-prompt-attachment-preview.md @@ -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. diff --git a/.changeset/jetbrains-prompt-attachments.md b/.changeset/jetbrains-prompt-attachments.md new file mode 100644 index 00000000000..efde8186459 --- /dev/null +++ b/.changeset/jetbrains-prompt-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support sending file and image attachments from the JetBrains chat prompt. diff --git a/.changeset/jetbrains-session-drop-overlay.md b/.changeset/jetbrains-session-drop-overlay.md new file mode 100644 index 00000000000..64be585e380 --- /dev/null +++ b/.changeset/jetbrains-session-drop-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support dropping files anywhere in a JetBrains chat session to add them to the prompt. diff --git a/.changeset/jetbrains-vfs-frontend-handoff.md b/.changeset/jetbrains-vfs-frontend-handoff.md new file mode 100644 index 00000000000..51914683663 --- /dev/null +++ b/.changeset/jetbrains-vfs-frontend-handoff.md @@ -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. diff --git a/.changeset/render-jetbrains-attachment-cards.md b/.changeset/render-jetbrains-attachment-cards.md new file mode 100644 index 00000000000..fc26c664e3c --- /dev/null +++ b/.changeset/render-jetbrains-attachment-cards.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render file attachments as attachment cards in JetBrains prompts and session history. diff --git a/.changeset/silent-comet-attachments.md b/.changeset/silent-comet-attachments.md new file mode 100644 index 00000000000..fb622dba660 --- /dev/null +++ b/.changeset/silent-comet-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open embedded transcript attachments in stable Kilo editor tabs. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index d0891ba871c..b31f8884192 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -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) { 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) } + } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 192ea1a1081..d35c96debd0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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() // ================================================================ @@ -233,6 +236,88 @@ object KiloCliDataParser { } } + class ChatEventNormalizer { + private val roles = mutableMapOf() + private val raw = mutableMapOf() + private val text = mutableMapOf() + + fun parse(type: String, data: String): List? { + 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 { + 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() + 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 { return parseTodosOrNull(raw) ?: emptyList() } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index cb8dbbe4001..156d2336639 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -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 = 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 = chat.events.filter { event -> val sid = when (event) { diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt index 194f9098b86..09df6ad5902 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatDtoSerializationTest.kt @@ -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 ------ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt index 507c7503c0c..29deb0cce41 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -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( diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 979e7738d3c..00fdd9ba1e3 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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('"') + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index f49f9ed8ee6..00e8a6be944 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -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 { val api = rpc diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 969466abc8f..a414a0adb2b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor( done(ok) } } + } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index e1d3f55bca7..98e63cbca2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService import ai.kilocode.client.migration.MigrationUiController import ai.kilocode.client.migration.MigrationUiState import ai.kilocode.client.migration.ui.MigrationOverlayPanel +import ai.kilocode.client.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) { + 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().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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 940437682e1..49d265623b2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -229,13 +229,13 @@ class SessionController( } } - fun prompt(text: String) { + fun prompt(text: String, files: List = 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 = 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 = buildMap { + private fun promptProps(files: List = emptyList()): Map = 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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index a8169f7749d..adf6fd84915 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt new file mode 100644 index 00000000000..ffb642a7e96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/PromptAttachment.kt @@ -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): List = 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())}" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 0533705f7e9..79eeb432f8f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -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, val limit: ModelLimitItem?, + val attachment: Boolean = false, ) { val key: String get() = "$provider/$id" } @@ -602,6 +620,7 @@ private fun parseModelKey(value: String): Pair? { 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 { 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}") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt new file mode 100644 index 00000000000..c0a99af67ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionDropOverlay.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 18c7d7410c9..6c2441890b2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt new file mode 100644 index 00000000000..2ae5cdc7596 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt @@ -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", "
"), +) + +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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt new file mode 100644 index 00000000000..38da71dc4f2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKind.kt @@ -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 = ref(params)?.filename ?: KiloBundle.message("session.attachment.title") + override fun icon(params: Map): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params)) + override fun presentablePath(params: Map): String { + val ref = ref(params) + return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params)) + } + + override fun isValid(params: Map): 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().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): 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().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() + 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().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().register(AttachmentEditorKind) +} + +internal fun attachmentParams( + sessionId: String, + messageId: String, + item: FileAttachment, + filename: String, + directory: String, +): Map = 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) } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt new file mode 100644 index 00000000000..371addaa17b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentOpeners.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index d0d4071c90e..337a6f8ea0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -57,6 +57,7 @@ class ModelPicker : PickerButton() { val recommendedIndex: Double? = null, val free: Boolean = false, val variants: List = emptyList(), + val attachment: Boolean = false, ) { val key: String get() = "$provider/$id" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt new file mode 100644 index 00000000000..e6b0ffbae5f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentPasteProvider.kt @@ -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 = + 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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt new file mode 100644 index 00000000000..9efa44aabf1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptAttachmentStrip.kt @@ -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() + + 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) + } + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index e154d67ccfc..f211cbc7982 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -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) -> Unit, private val onAbort: () -> Unit, private val onEnhance: (String, (Result) -> 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() + 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,10 +394,132 @@ 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) = 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?, + 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 { + 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 9ffd77482dd..1d830241428 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt new file mode 100644 index 00000000000..50f07635b1a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index d0b5e06b445..84e8d9401e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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() private val sources = LinkedHashMap() + 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]. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt new file mode 100644 index 00000000000..a01148a25fc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt @@ -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() + private val cards = LinkedHashMap() + 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 = 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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 459509ffc93..e00944d1282 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index a13dde6e1ce..35d48bfde5b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -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) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 81744bfb98b..37dffc06fc3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -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*") - .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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt index 311bee99c90..1ff400346b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/LayeredOverlayPanel.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt new file mode 100644 index 00000000000..e3276f68529 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKind.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt new file mode 100644 index 00000000000..207e79da489 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloEditorKindRegistry.kt @@ -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() + + fun register(kind: KiloEditorKind) { + kinds[kind.id] = kind + service().register(kind) + } + + fun unregister(id: String) { + kinds.remove(id) + service().unregister(id) + } + + fun get(id: String): KiloEditorKind? = kinds[id] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt new file mode 100644 index 00000000000..1b0dc0f7605 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditor.kt @@ -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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt new file mode 100644 index 00000000000..4fc5b296c66 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorBase.kt @@ -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 +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt new file mode 100644 index 00000000000..1bcbbbcb27e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloFileEditorProvider.kt @@ -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().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().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) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt new file mode 100644 index 00000000000..a2773afa1b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloPath.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.vfs + +import kotlinx.serialization.Serializable + +@Serializable +data class KiloPath( + val kind: String, + val params: Map = emptyMap(), +) { + fun canonical(): KiloPath = copy(params = canonicalParams(params)) +} + +internal fun canonicalParams(params: Map): Map { + if (params.size < 2) return params + return params.toSortedMap() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt new file mode 100644 index 00000000000..b23d0f69a05 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVfsManager.kt @@ -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 = 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 = emptyMap()) { + val file = file(kind, params) ?: return + FileEditorManager.getInstance(project).closeFile(file) + KiloVirtualFileSystem.getInstance().release(file.path) + } + + @RequiresEdt + fun updatePresentation(kind: String, params: Map = emptyMap()) { + val file = file(kind, params) ?: return + FileEditorManager.getInstance(project).updateFilePresentation(file) + } + + private fun file(kind: String, params: Map): KiloVirtualFile? { + val path = KiloPath(kind, params) + val fs = KiloVirtualFileSystem.getInstance() + return fs.refreshAndFindFileByPath(fs.getPath(path)) as? KiloVirtualFile + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt new file mode 100644 index 00000000000..01b14336dd7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFile.kt @@ -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 = 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().get(path.kind) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt new file mode 100644 index 00000000000..0dc059356b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKind.kt @@ -0,0 +1,15 @@ +package ai.kilocode.client.vfs + +import javax.swing.Icon + +interface KiloVirtualFileKind { + val id: String + + fun title(params: Map): String + + fun icon(params: Map): Icon? = null + + fun presentablePath(params: Map): String = title(params) + + fun isValid(params: Map): Boolean = true +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt new file mode 100644 index 00000000000..89af66be9e0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileKindRegistry.kt @@ -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() + + fun register(kind: KiloVirtualFileKind) { + kinds[kind.id] = kind + } + + fun unregister(id: String) { + kinds.remove(id) + } + + fun get(id: String): KiloVirtualFileKind? = kinds[id] +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt new file mode 100644 index 00000000000..c87aa9e1911 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/vfs/KiloVirtualFileSystem.kt @@ -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() + + fun getPath(path: KiloPath): String = json.encodeToString(KiloPath.serializer(), path.canonical()) + + fun findOrCreateFile(path: KiloPath): VirtualFile? { + service().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() + 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) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg new file mode 100644 index 00000000000..437e5035017 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg new file mode 100644 index 00000000000..e604cb68874 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove-hover_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg new file mode 100644 index 00000000000..fe390112568 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg new file mode 100644 index 00000000000..a00b482d888 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/remove_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index de1f491c9a6..2c8207d401e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -9,6 +9,7 @@ + + + (ui) + val drop = find(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(ui) + + assertNull(drop.dropTarget) + } + + fun `test prompt file drag leave does not immediately hide drop overlay`() { + val prompt = find(ui) + val drop = find(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(ui) + val drop = find(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(ui) + val drop = find(ui) + val account = find(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 } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 6e5d17d406c..ab1ea65096b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -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 = 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( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 2edb90556de..0d6bb6b29b0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -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("")) + assertTrue(card.toolTipText.contains("Name: a.txt
Type: text/plain
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().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) -> 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) -> 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) -> 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 { + return components(root).filterIsInstance() + } + + private fun components(root: java.awt.Component): List { + val out = mutableListOf() + 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) : Transferable { + override fun getTransferDataFlavors(): Array = 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 = 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, + private val image: BufferedImage, + ) : Transferable { + override fun getTransferDataFlavors(): Array = 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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt index f7e69f4ca46..6f8be31660e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionRootPanelTest.kt @@ -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 { + 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 } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 880899b007e..9f0b410b141 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -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() + 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().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().size) + assertEquals(1, msg.components.filterIsInstance().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().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>() + 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 find(root: java.awt.Component, type: Class): 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 findAll(root: java.awt.Component, type: Class): List { + val out = mutableListOf() + 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 { + val out = mutableListOf() + 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 + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt new file mode 100644 index 00000000000..b18e32a35d1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentEditorKindTest.kt @@ -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() + 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() + 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"), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt new file mode 100644 index 00000000000..43df4520ac3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/tool/KiloCliParserTest.kt @@ -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 = """ + + /tmp/example.txt + + file + """.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")) + assertNull(KiloCliParser.tag("file", "path")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index ece8e520c45..eac63e23a1c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -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() var historyGate: CompletableDeferred? = null + var historyCalls = 0 + private set /** Recent sessions returned by [recent]. */ val recent = mutableListOf() @@ -82,6 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { var enhanceGate: CompletableDeferred? = null var enhanceThrows: Exception? = null val prompts = mutableListOf>() + val attachmentParts = mutableListOf() val aborts = mutableListOf>() val compacts = mutableListOf>() val configs = mutableListOf>() @@ -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 { 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 { 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) } + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt new file mode 100644 index 00000000000..417c80037a8 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/cli/KiloCliParser.kt @@ -0,0 +1,18 @@ +package ai.kilocode.cli + +import java.util.concurrent.ConcurrentHashMap + +object KiloCliParser { + private val tags = ConcurrentHashMap() + + fun tag(text: String, name: String): String? = + tags.computeIfAbsent(name) { + val tag = Regex.escape(it) + Regex("<$tag>\\s*([\\s\\S]*?)\\s*") + } + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 664f5e22e57..5283789459c 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -50,14 +50,23 @@ object ChatLogSummary { fun prompt(prompt: PromptDto): String { val out = mutableListOf() - 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" } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index c6fc43f9202..fe53f8c35ee 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -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 { /** Load message history for a session. */ suspend fun messages(id: String, directory: String): List + /** 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 diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index e5c85cb0ab2..21bf20897b0 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -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 ---