Merge pull request #11077 from Kilo-Org/balanced-backpack

feat(jetbrains): support prompt and transcript attachments
This commit is contained in:
Kirill Kalishev
2026-06-15 11:06:14 -04:00
committed by GitHub
68 changed files with 3656 additions and 93 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Hide generated read-tool payload lines from JetBrains prompt bubbles while keeping attachments and assistant tool output visible.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains prompt attachments in one horizontal scrolling row in session history.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support pasting files and images into JetBrains chat prompts as attachments.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show JetBrains prompt attachments inside the prompt bubble with previews and open embedded attachments in editor tabs.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support sending file and image attachments from the JetBrains chat prompt.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support dropping files anywhere in a JetBrains chat session to add them to the prompt.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open embedded JetBrains message attachments in frontend-managed Kilo editor tabs with loading and connection retry states.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render file attachments as attachment cards in JetBrains prompts and session history.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open embedded transcript attachments in stable Kilo editor tabs.
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -79,6 +80,7 @@ class KiloBackendChatManager(
private var client: OkHttpClient? = null
private var base: String? = null
private var watcher: Job? = null
private var normalizer = KiloCliDataParser.ChatEventNormalizer()
fun start(http: OkHttpClient, port: Int, sse: SharedFlow<SseEvent>) {
client = http
@@ -87,16 +89,18 @@ class KiloBackendChatManager(
watcher = cs.launch {
sse.collect { event ->
if (event.type in CHAT_EVENTS) {
val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data)
if (parsed != null) {
log.debug { ChatLogSummary.event(parsed) }
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
log.info(
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
)
val events = normalizer.parse(event.type, event.data)
if (events != null) {
for (parsed in events) {
log.debug { ChatLogSummary.event(parsed) }
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
log.info(
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
)
}
_events.emit(parsed)
}
_events.emit(parsed)
} else {
log.warn("SSE parse returned null for type=${event.type} bytes=${event.data.length}")
}
@@ -111,6 +115,7 @@ class KiloBackendChatManager(
watcher = null
client = null
base = null
normalizer = KiloCliDataParser.ChatEventNormalizer()
log.info("Chat manager stopped")
}
@@ -251,6 +256,17 @@ class KiloBackendChatManager(
}
}
fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? {
return messages(id, dir)
.firstOrNull { it.info.id == message }
?.parts
?.firstOrNull {
if (it.type != "file") return@firstOrNull false
if (!key.isNullOrBlank()) attachmentKey(it.id, it.filename.orEmpty(), it.url.orEmpty()) == key
else it.id == part
}
}
// ------ config update ------
fun updateConfig(dir: String, update: ConfigUpdateDto) {
@@ -370,4 +386,10 @@ class KiloBackendChatManager(
private fun encode(value: String): String =
java.net.URLEncoder.encode(value, "UTF-8")
private fun attachmentKey(part: String, name: String, url: String): String {
val value = listOf(part, name, url).joinToString("\u0000")
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return bytes.take(16).joinToString("") { "%02x".format(it) }
}
}
@@ -24,6 +24,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartTimeDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
@@ -68,6 +69,8 @@ object KiloCliDataParser {
private val json = Json { ignoreUnknownKeys = true }
private val pretty = Json { ignoreUnknownKeys = true; prettyPrint = true }
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
private val READ_TOOL_LINE = Regex("^\\s*Called\\s+the\\s+Read\\s+tool\\s+with\\s+the\\s+following\\s+input:", RegexOption.IGNORE_CASE)
private val READ_TOOL_PATH = Regex("\"(?:filePath|path)\"\\s*:")
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
// ================================================================
@@ -233,6 +236,88 @@ object KiloCliDataParser {
}
}
class ChatEventNormalizer {
private val roles = mutableMapOf<String, String>()
private val raw = mutableMapOf<Key, String>()
private val text = mutableMapOf<Key, String>()
fun parse(type: String, data: String): List<ChatEventDto>? {
val event = parseChatEvent(type, data) ?: return null
return when (event) {
is ChatEventDto.MessageUpdated -> {
roles[event.info.id] = event.info.role
listOf(event)
}
is ChatEventDto.MessageRemoved -> {
roles.remove(event.messageID)
clear(event.messageID)
listOf(event)
}
is ChatEventDto.PartUpdated -> listOf(update(event))
is ChatEventDto.PartDelta -> delta(event)
is ChatEventDto.PartRemoved -> {
val key = Key(event.messageID, event.partID)
raw.remove(key)
text.remove(key)
listOf(event)
}
else -> listOf(event)
}
}
private fun update(event: ChatEventDto.PartUpdated): ChatEventDto {
val part = event.part
val key = Key(part.messageID, part.id)
if (roles[part.messageID] != "user" || part.type != "text") {
raw.remove(key)
text.remove(key)
return event
}
val value = part.text.orEmpty()
val clean = sanitizeUserPromptText(value)
raw[key] = value
text[key] = clean
return event.copy(part = part.copy(text = clean))
}
private fun delta(event: ChatEventDto.PartDelta): List<ChatEventDto> {
if (event.field != "text" || roles[event.messageID] != "user") return listOf(event)
val key = Key(event.messageID, event.partID)
val prev = text[key].orEmpty()
val next = raw[key].orEmpty() + event.delta
val clean = sanitizeUserPromptText(next)
raw[key] = next
text[key] = clean
if (clean == prev) return emptyList()
if (clean.startsWith(prev)) return listOf(event.copy(delta = clean.removePrefix(prev)))
return listOf(ChatEventDto.PartUpdated(
sessionID = event.sessionID,
part = PartDto(
id = event.partID,
sessionID = event.sessionID,
messageID = event.messageID,
type = "text",
text = clean,
),
))
}
private fun clear(id: String) {
raw.keys.filter { it.messageID == id }.forEach(raw::remove)
text.keys.filter { it.messageID == id }.forEach(text::remove)
}
private data class Key(val messageID: String, val partID: String)
}
/**
* Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair.
* Returns null if the required fields are missing.
@@ -267,14 +352,36 @@ object KiloCliDataParser {
return arr.mapNotNull { elem ->
val obj = elem.jsonObject
val info = obj["info"]?.jsonObject ?: return@mapNotNull null
val msg = parseMessage(info)
val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList())
MessageWithPartsDto(
info = parseMessage(info),
parts = parts.map { parsePart(it.jsonObject) },
info = msg,
parts = parts.map { sanitizePart(parsePart(it.jsonObject), msg.role) },
)
}
}
internal fun sanitizeUserPromptText(text: String): String {
val lines = text.lines()
if (lines.none(::readPayload)) return text
val out = mutableListOf<String>()
var gap = false
for (line in lines) {
if (readPayload(line)) {
gap = true
continue
}
if (line.isBlank() && out.lastOrNull()?.isBlank() == true && gap) {
gap = false
continue
}
out.add(line)
if (line.isNotBlank()) gap = false
}
return out.joinToString("\n")
}
fun parseCloudSessions(raw: String): CloudSessionListDto {
val obj = tryParseObject(raw) ?: return CloudSessionListDto(emptyList())
val items = obj["cliSessions"]?.jsonArray ?: JsonArray(emptyList())
@@ -377,7 +484,7 @@ object KiloCliDataParser {
*/
fun buildPromptJson(prompt: PromptDto): String {
val parts = prompt.parts.joinToString(",") { part ->
"""{"type":"${part.type}","text":${escape(part.text)}}"""
buildPromptPartJson(part)
}
val sb = StringBuilder()
sb.append("""{"parts":[$parts]""")
@@ -406,6 +513,18 @@ object KiloCliDataParser {
return sb.toString()
}
private fun buildPromptPartJson(part: PromptPartDto): String {
val fields = mutableListOf("\"type\":${escape(part.type)}")
if (part.type == "file") {
part.mime?.let { fields += "\"mime\":${escape(it)}" }
part.url?.let { fields += "\"url\":${escape(it)}" }
part.filename?.let { fields += "\"filename\":${escape(it)}" }
return "{${fields.joinToString(",")}}"
}
fields += "\"text\":${escape(part.text.orEmpty())}"
return "{${fields.joinToString(",")}}"
}
/**
* Build the JSON body for `POST /session/{id}/summarize`.
*/
@@ -509,6 +628,9 @@ object KiloCliDataParser {
messageID = obj.str("messageID") ?: "",
type = obj.str("type") ?: "unknown",
text = obj.str("text"),
mime = obj.str("mime"),
url = obj.str("url"),
filename = obj.str("filename"),
tool = obj.str("tool"),
callID = obj.str("callID"),
state = state?.str("status"),
@@ -526,6 +648,16 @@ object KiloCliDataParser {
)
}
private fun sanitizePart(part: PartDto, role: String): PartDto {
if (role != "user" || part.type != "text") return part
return part.copy(text = part.text?.let(::sanitizeUserPromptText))
}
private fun readPayload(line: String): Boolean {
if (!READ_TOOL_LINE.containsMatchIn(line)) return false
return READ_TOOL_PATH.containsMatchIn(line)
}
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
return parseTodosOrNull(raw) ?: emptyList()
}
@@ -16,6 +16,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -118,6 +119,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
ready { chat.messages(id, directory) }
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> =
chat.events.filter { event ->
val sid = when (event) {
@@ -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 ------
@@ -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(
@@ -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('"')
}
}
@@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -185,6 +186,9 @@ class KiloSessionService internal constructor(
call { messages(id, dir) }
.also { LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } }
suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? =
call { attachmentPart(id, dir, message, part, key) }
/** Subscribe to streaming chat events for a session. */
fun events(id: String, dir: String): Flow<ChatEventDto> {
val api = rpc
@@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor(
done(ok)
}
}
}
@@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService
import ai.kilocode.client.migration.MigrationUiController
import ai.kilocode.client.migration.MigrationUiState
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.scroll.SessionScroll
@@ -19,8 +20,13 @@ import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionDropOverlay
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind
import ai.kilocode.client.session.ui.attachment.attachmentParams
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -35,7 +41,10 @@ import ai.kilocode.client.session.views.question.QuestionView
import ai.kilocode.client.settings.profile.UserProfileConfigurable
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.vfs.KiloVfsManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.PromptPartDto
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import com.intellij.ide.BrowserUtil
@@ -64,8 +73,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BorderLayout
import java.awt.event.HierarchyEvent
import java.net.URI
import java.nio.file.Path
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.Timer
import javax.swing.UIManager
/**
@@ -89,6 +101,7 @@ class SessionUi(
companion object {
private val LOG = KiloLog.create(SessionUi::class.java)
private const val HIDE_MS = 120
}
private val project = project
@@ -125,6 +138,13 @@ class SessionUi(
private lateinit var root: SessionRootPanel
private lateinit var account: SessionAccountOverlay
private lateinit var drop: SessionDropOverlay
private val hide = Timer(HIDE_MS) {
if (disposed || !this::drop.isInitialized) return@Timer
drop.setActive(false)
}.apply {
isRepeats = false
}
private lateinit var sessionContent: JPanel
@@ -296,6 +316,7 @@ class SessionUi(
::openFile,
::openUrl,
selection,
::openAttachment,
repo = workspace.directory,
resize = { anchor, fn -> scroll.preserve(anchor, fn) },
)
@@ -306,11 +327,21 @@ class SessionUi(
prompt = PromptPanel(
project = project,
onSend = { text -> sendPrompt(text) },
onSend = { text, files -> sendPrompt(text, files) },
onAbort = { controller.abort() },
onEnhance = controller::enhancePrompt,
)
drop = SessionDropOverlay()
root.addOverlay(drop) { pane, _ ->
java.awt.Rectangle(0, 0, pane.width, pane.height)
}
root.overlay.setComponentZOrder(drop, 0)
prompt.onFileDrag = ::syncDrop
prompt.installFileDrop(root, "session-root")
// The visual overlay returns contains(false) so normal UI remains clickable.
// Registering it as a native DnD target makes IntelliJ resolve a null over-component.
sessionContent.add(header, BorderLayout.NORTH)
sessionContent.add(scroll.component, BorderLayout.CENTER)
root.content.add(sessionContent, BorderLayout.CENTER)
@@ -320,7 +351,10 @@ class SessionUi(
private fun bindUi() {
prompt.mode.onSelect = { item -> controller.selectAgent(item.id) }
prompt.model.onSelect = { item -> controller.selectModel(item.provider, item.id) }
prompt.model.onSelect = { item ->
prompt.setAttachmentEnabled(item.attachment)
controller.selectModel(item.provider, item.id)
}
prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) }
prompt.onReset = { controller.clearModelOverride() }
prompt.onChange = { scroll.refresh() }
@@ -356,14 +390,16 @@ class SessionUi(
it.display,
it.provider,
it.providerName,
it.recommendedIndex,
it.free,
it.variants,
)
it.recommendedIndex,
it.free,
it.variants,
it.attachment,
)
}
val selected =
m.model?.let { full -> items.firstOrNull { it.key == full }?.key }
prompt.model.setItems(items, selected)
prompt.setAttachmentEnabled(items.firstOrNull { it.key == selected }?.attachment ?: true)
prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant)
prompt.setResetVisible(m.modelOverride)
prompt.setReady(m.isReady())
@@ -432,6 +468,17 @@ class SessionUi(
}
}
@RequiresEdt
private fun syncDrop(value: Boolean) {
if (disposed) return
if (value) {
hide.stop()
drop.setActive(true)
return
}
hide.restart()
}
private fun bindMigration() {
cs.launch {
migration.state.collect { state ->
@@ -515,16 +562,20 @@ class SessionUi(
}
}
private fun sendPrompt(text: String) {
if (text.isBlank()) return
private fun sendPrompt(text: String, files: List<PromptPartDto>) {
if (text.isBlank() && files.isEmpty()) return
val parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(files)
}
LOG.debug {
val agent = controller.model.agent ?: "none"
val model = controller.model.model ?: "none"
"${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}"
"${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}"
}
prompt.clear()
val follow = scroll.atBottom()
controller.prompt(text)
controller.prompt(text, files)
scroll.followBottom(follow)
}
@@ -538,6 +589,54 @@ class SessionUi(
BrowserUtil.browse(url)
}
private fun openAttachment(messageId: String, item: FileAttachment) {
val url = item.url.takeIf { it.isNotBlank() } ?: run {
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
return
}
LOG.info(
"kind=attachment-open session=${controller.id ?: "none"} message=$messageId part=${item.id} " +
"name=${attachmentName(item)} mime=${item.mime} url=${attachmentUrl(url)} dir=${workspace.directory}"
)
if (isEmbeddedAttachment(url)) {
val id = controller.id ?: run {
LOG.info("kind=attachment-open skipped=true reason=missing-session message=$messageId part=${item.id} name=${attachmentName(item)}")
return
}
LOG.info("kind=attachment-open route=kilo-vfs session=$id message=$messageId part=${item.id} name=${attachmentName(item)}")
ensureAttachmentEditorKind()
project.service<KiloVfsManager>().open(
AttachmentEditorKind.ID,
attachmentParams(id, messageId, item, attachmentName(item), workspace.directory),
)
return
}
val uri = runCatching { URI.create(url) }.getOrNull() ?: run {
LOG.info("kind=attachment-open skipped=true reason=invalid-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
return
}
if (uri.scheme == "file") {
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run {
LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
return
}
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path")
openFile(path)
return
}
LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}")
openUrl(url)
}
private fun attachmentName(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() }
?: "attachment"
private fun attachmentUrl(url: String): String {
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}"
}
private fun onStateChanged(state: SessionState) {
if (disposed) return
prompt.setBusy(state.isBusy())
@@ -596,6 +695,7 @@ class SessionUi(
override fun dispose() {
disposed = true
hide.stop()
modalFocus = null
empty = null
if (this::root.isInitialized) root.setModalContent(null)
@@ -229,13 +229,13 @@ class SessionController(
}
}
fun prompt(text: String) {
fun prompt(text: String, files: List<PromptPartDto> = emptyList()) {
assertEdt()
val start = sid ?: ref?.key ?: "pending"
val exists = sid != null
val dto = promptDto(text)
val props = promptProps()
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
val dto = promptDto(text, files)
val props = promptProps(files)
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf(
"source" to "user",
"hasExistingSession" to exists.toString(),
@@ -658,11 +658,12 @@ class SessionController(
info.name,
provider.id,
provider.name,
info.recommendedIndex,
info.free,
info.variants,
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
)
info.recommendedIndex,
info.free,
info.variants,
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
info.attachment,
)
}
}
} ?: emptyList()
@@ -1237,12 +1238,16 @@ class SessionController(
}
}
private fun promptDto(text: String): PromptDto {
private fun promptDto(text: String, files: List<PromptPartDto> = emptyList()): PromptDto {
val full = model.model
val sel = full?.let(::parseModel)
val variant = model.variant?.takeIf { it in model.variants }
val parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(files)
}
return PromptDto(
parts = listOf(PromptPartDto(type = "text", text = text)),
parts = parts,
providerID = sel?.first,
modelID = sel?.second,
agent = model.agent,
@@ -1455,7 +1460,7 @@ class SessionController(
}
}
private fun promptProps(): Map<String, String> = buildMap {
private fun promptProps(files: List<PromptPartDto> = emptyList()): Map<String, String> = buildMap {
model.agent?.let { put("agent", it) }
model.model?.let { key ->
put("model", key)
@@ -1465,6 +1470,10 @@ class SessionController(
}
}
model.variant?.takeIf { it in model.variants }?.let { put("variant", it) }
if (files.isNotEmpty()) {
put("attachmentCount", files.size.toString())
put("mediaAttachmentCount", files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }.toString())
}
}
private fun bucket(text: String): String = when (text.length) {
@@ -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
@@ -0,0 +1,101 @@
package ai.kilocode.client.session.model
import ai.kilocode.rpc.dto.PromptPartDto
import java.awt.Image
import java.awt.image.BufferedImage
import java.awt.image.MultiResolutionImage
import java.io.ByteArrayOutputStream
import java.util.Base64
import java.util.UUID
import javax.imageio.ImageIO
import java.nio.file.Path
import kotlin.io.path.name
import kotlin.io.path.readBytes
data class PromptAttachment(
val id: String,
val name: String,
val mime: String,
val url: String,
val path: Path? = null,
) {
fun part() = PromptPartDto(
type = "file",
mime = mime,
url = path?.let { data(it, mime) } ?: url,
filename = name,
)
}
object PromptAttachmentExtractor {
private const val MAX_BYTES = 10 * 1024 * 1024
fun files(files: List<java.io.File>): List<PromptAttachment> = files
.filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES }
.map { file ->
val path = file.toPath()
val mime = mime(file)
if (!media(mime)) return@map null
PromptAttachment(
id = path.toAbsolutePath().normalize().toString(),
name = path.fileName?.toString() ?: path.name,
mime = mime,
url = path.toUri().toString(),
path = path,
)
}
.filterNotNull()
fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain"
fun image(raw: Any): PromptAttachment? {
val image = when (raw) {
is MultiResolutionImage -> raw.resolutionVariants.firstOrNull()?.buffered()
is BufferedImage -> raw
is Image -> raw.buffered()
else -> null
} ?: return null
val out = ByteArrayOutputStream()
ImageIO.write(image, "png", out)
val id = UUID.randomUUID().toString()
val data = Base64.getEncoder().encodeToString(out.toByteArray())
return PromptAttachment(
id = "clipboard-image:$id",
name = "pasted-image-$id.png",
mime = "image/png",
url = "data:image/png;base64,$data",
)
}
private fun mime(file: java.io.File): String {
if (file.isDirectory) return "application/x-directory"
return when (file.extension.lowercase()) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"webp" -> "image/webp"
"bmp" -> "image/bmp"
"svg" -> "image/svg+xml"
"pdf" -> "application/pdf"
"txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp" -> "text/plain"
else -> "application/octet-stream"
}
}
private fun Image.buffered(): BufferedImage? {
if (this is BufferedImage) return this
val width = getWidth(null)
val height = getHeight(null)
if (width <= 0 || height <= 0) return null
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
val g = image.createGraphics()
try {
g.drawImage(this, 0, 0, null)
} finally {
g.dispose()
}
return image
}
}
private fun data(path: Path, mime: String) = "data:$mime;base64,${Base64.getEncoder().encodeToString(path.readBytes())}"
@@ -158,6 +158,10 @@ class SessionModel {
if (dto.type in SILENT_PART_TYPES) return
val msg = entries[messageId] ?: return
val existing = msg.parts[dto.id]
if (empty(dto)) {
if (existing is Text) removeContent(messageId, dto.id)
return
}
if (existing != null) {
updateExisting(messageId, existing, dto)
return
@@ -243,6 +247,7 @@ class SessionModel {
val item = Message(msg.info)
for (part in msg.parts) {
if (part.type in SILENT_PART_TYPES) continue
if (empty(part)) continue
val content = fromDto(part, part.text)
item.parts[content.id] = content
}
@@ -373,6 +378,11 @@ class SessionModel {
existing.content.append(text)
existing.done = dto.time?.end != null || dto.time == null
}
is FileAttachment -> {
existing.mime = dto.mime ?: "application/octet-stream"
existing.url = dto.url ?: ""
existing.filename = dto.filename
}
is Tool -> {
existing.kind = toolKind(dto.tool)
existing.state = parseToolState(dto.state)
@@ -398,6 +408,8 @@ class SessionModel {
updateHeader()
}
private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
val content = text ?: dto.text
return when (dto.type) {
@@ -408,6 +420,11 @@ class SessionModel {
if (content != null && content.isNotEmpty()) this.content.append(content)
done = dto.time?.end != null || dto.time == null
}
"file" -> FileAttachment(dto.id).apply {
mime = dto.mime ?: "application/octet-stream"
url = dto.url ?: ""
filename = dto.filename
}
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
state = parseToolState(dto.state)
callId = dto.callID
@@ -568,6 +585,7 @@ data class ModelItem(
val free: Boolean,
val variants: List<String>,
val limit: ModelLimitItem?,
val attachment: Boolean = false,
) {
val key: String get() = "$provider/$id"
}
@@ -602,6 +620,7 @@ private fun parseModelKey(value: String): Pair<String, String>? {
private fun Content.timelineTitle(): String = when (this) {
is Text -> "Text"
is Reasoning -> "Reasoning"
is FileAttachment -> filename?.takeIf { it.isNotBlank() } ?: "File"
is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name
is Compaction -> "Compaction"
is StepFinish -> "Step finish"
@@ -635,6 +654,7 @@ private fun tail(path: String): String {
private fun Content.weight(): Int = when (this) {
is Text -> content.length / 200 + 1
is Reasoning -> content.length / 200 + 1
is FileAttachment -> 1
is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1
is Compaction -> 2
is StepFinish -> tokens?.stepWeight() ?: 1
@@ -661,6 +681,7 @@ private fun renderMessage(msg: Message): List<String> {
out.add("reasoning#${part.id} done=${part.done}:")
out.addAll(renderText(part.content))
}
is FileAttachment -> out.add("file#${part.id} ${part.mime} ${part.filename ?: tail(part.url)}")
is Tool -> out.add(renderTool(part))
is Compaction -> out.add("compaction#${part.id}")
is StepFinish -> out.add("step-finish#${part.id}")
@@ -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)
}
}
@@ -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
@@ -0,0 +1,299 @@
package ai.kilocode.client.session.ui.attachment
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.text.StringUtil
import com.intellij.xml.util.XmlStringUtil
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Container
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.Image
import java.awt.LayoutManager2
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.io.ByteArrayInputStream
import java.net.URI
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import javax.imageio.ImageIO
import javax.swing.Icon
import javax.swing.ImageIcon
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
data class AttachmentCardItem(
val name: String,
val mime: String,
val url: String,
val path: Path? = null,
)
open class AttachmentCard(
private val item: AttachmentCardItem,
remove: (() -> Unit)? = null,
open: (() -> Unit)? = null,
) : JPanel(CardLayout()) {
private var gen = 0
private var loaded = false
private val icon = attachmentIcon(item.mime, item.name)
private val tip = tooltip(item)
private val open = open?.let { callback ->
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
callback()
}
}
}
private val hover = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
showAction(true)
}
override fun mouseMoved(e: MouseEvent) {
showAction(true)
}
override fun mouseExited(e: MouseEvent) {
val point = SwingUtilities.convertPoint(e.component, e.point, this@AttachmentCard)
showAction(contains(point))
}
}
private val preview = PreviewPanel(::watch).apply { setIcon(icon) }
private val content = JPanel(BorderLayout()).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.xs())
add(preview, BorderLayout.CENTER)
}
private val action = remove?.let { callback ->
CloseButton().apply {
isVisible = false
toolTipText = KiloBundle.message("prompt.attachment.remove", item.name)
accessibleContext?.accessibleName = toolTipText
addActionListener { callback() }
}
}
init {
isOpaque = false
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
toolTipText = tip
accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name)
add(content)
if (action != null) {
add(action)
setComponentZOrder(action, 0)
}
watch(this)
}
override fun getPreferredSize(): Dimension = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH,
SessionUiStyle.View.Attachment.CARD_HEIGHT,
)
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun addNotify() {
super.addNotify()
if (loaded) return
loaded = true
load()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
g2.color = SessionUiStyle.View.Surface.bgColor()
g2.fillRoundRect(0, 0, width, height, arc, arc)
g2.color = SessionUiStyle.View.Outline.color()
g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
@RequiresEdt
private fun load() {
if (!item.mime.startsWith("image/")) return
val stamp = ++gen
val size = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
)
ApplicationManager.getApplication().executeOnPooledThread {
val image = runCatching {
val data = decodeDataImage(item.url)
val path = local(item)
if (data != null) ImageIO.read(ByteArrayInputStream(data)) else path?.let { ImageIO.read(it.toFile()) }
}.getOrNull()
val scaled = image?.let { scale(it, size.width, size.height) }
if (scaled == null) return@executeOnPooledThread
ApplicationManager.getApplication().invokeLater {
if (gen != stamp || !isDisplayable) return@invokeLater
preview.setIcon(ImageIcon(scaled))
}
}
}
private fun watch(node: Component) {
if (node is JComponent && node !is JButton) node.toolTipText = tip
node.removeMouseListener(hover)
node.removeMouseMotionListener(hover)
node.addMouseListener(hover)
node.addMouseMotionListener(hover)
open?.let {
node.removeMouseListener(it)
if (node !is JButton) {
node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
node.addMouseListener(it)
}
}
if (node is Container) node.components.forEach(::watch)
}
private fun showAction(value: Boolean) {
val button = action ?: return
if (button.isVisible == value) return
button.isVisible = value
revalidate()
repaint()
}
private class PreviewPanel(private val watch: (Component) -> Unit) : JPanel(BorderLayout()) {
init {
isOpaque = false
}
override fun getPreferredSize(): Dimension = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
)
fun setIcon(next: Icon) {
val label = JBLabel(next, SwingConstants.CENTER).align(HAlign.CENTER, VAlign.CENTER)
removeAll()
add(label, BorderLayout.CENTER)
watch(label)
revalidate()
repaint()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
g2.color = SessionUiStyle.View.Surface.headerHoverBgColor()
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
}
private class CardLayout : LayoutManager2 {
override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit
override fun addLayoutComponent(name: String?, comp: Component) = Unit
override fun removeLayoutComponent(comp: Component) = Unit
override fun minimumLayoutSize(parent: Container) = preferredLayoutSize(parent)
override fun preferredLayoutSize(parent: Container) = JBUI.size(
SessionUiStyle.View.Attachment.CARD_WIDTH,
SessionUiStyle.View.Attachment.CARD_HEIGHT,
)
override fun maximumLayoutSize(target: Container) = preferredLayoutSize(target)
override fun getLayoutAlignmentX(target: Container) = 0f
override fun getLayoutAlignmentY(target: Container) = 0f
override fun invalidateLayout(target: Container) = Unit
override fun layoutContainer(parent: Container) {
val size = JBUI.scale(SessionUiStyle.View.Attachment.CLOSE_SIZE)
for (i in 0 until parent.componentCount) {
val child = parent.getComponent(i)
if (child is JButton) {
child.setBounds(parent.width - size - UiStyle.Gap.xs(), UiStyle.Gap.xs(), size, size)
continue
}
child.setBounds(0, 0, parent.width, parent.height)
}
}
}
private class CloseButton : JButton() {
init {
iconButton(this)
icon = REMOVE_ICON
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
icon = REMOVE_HOVER_ICON
}
override fun mouseExited(e: MouseEvent) {
icon = REMOVE_ICON
}
})
}
}
companion object {
private val REMOVE_ICON: Icon = IconLoader.getIcon("/icons/remove.svg", AttachmentCard::class.java)
private val REMOVE_HOVER_ICON: Icon = IconLoader.getIcon("/icons/remove-hover.svg", AttachmentCard::class.java)
}
}
private fun scale(image: Image, width: Int, height: Int): Image {
val iw = image.getWidth(null)
val ih = image.getHeight(null)
if (iw <= 0 || ih <= 0) return image
val ratio = minOf(width.toDouble() / iw, height.toDouble() / ih)
val w = maxOf(1, (iw * ratio).toInt())
val h = maxOf(1, (ih * ratio).toInt())
return image.getScaledInstance(w, h, Image.SCALE_SMOOTH)
}
private fun local(item: AttachmentCardItem): Path? {
if (item.path != null) return item.path
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return null
if (uri.scheme != "file") return null
return runCatching { Path.of(uri) }.getOrNull()
}
private fun tooltip(item: AttachmentCardItem): String = XmlStringUtil.wrapInHtml(
StringUtil.escapeXmlEntities(
KiloBundle.message("prompt.attachment.tooltip", item.name, item.mime, location(item)),
).replace("\n", "<br>"),
)
private fun location(item: AttachmentCardItem): String {
if (item.path != null) return item.path.toString()
val uri = runCatching { URI.create(item.url) }.getOrNull()
if (uri?.scheme == "data") return KiloBundle.message("prompt.attachment.embedded")
if (uri?.scheme == "file") return runCatching { Path.of(uri).toString() }
.getOrElse { URLDecoder.decode(uri.rawSchemeSpecificPart.removePrefix("//"), StandardCharsets.UTF_8) }
return item.url
}
@@ -0,0 +1,312 @@
package ai.kilocode.client.session.ui.attachment
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.vfs.KiloEditorKind
import ai.kilocode.client.vfs.KiloEditorKindRegistry
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.security.MessageDigest
import javax.imageio.ImageIO
import javax.swing.Icon
import javax.swing.ImageIcon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingConstants
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal object AttachmentEditorKind : KiloEditorKind {
const val ID = "attachment"
override val id: String = ID
override fun title(params: Map<String, String>): String = ref(params)?.filename ?: KiloBundle.message("session.attachment.title")
override fun icon(params: Map<String, String>): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params))
override fun presentablePath(params: Map<String, String>): String {
val ref = ref(params)
return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params))
}
override fun isValid(params: Map<String, String>): Boolean = ref(params) != null
@RequiresEdt
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
val panel = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
}
panel.add(component(AttachmentData.Connecting), BorderLayout.CENTER)
val ref = ref(file.path.params)
LOG.info("kind=attachment-editor phase=create-content valid=${ref != null} project=${project.name} hash=${project.locationHash} ref=${ref?.let(::brief) ?: "invalid"}")
if (ref == null) {
panel.removeAll()
panel.add(component(AttachmentData.Missing), BorderLayout.CENTER)
return panel
}
project.service<KiloAttachmentEditorService>().load(ref, parent) { data ->
LOG.info("kind=attachment-editor phase=render data=${describe(data)} ref=${brief(ref)}")
panel.removeAll()
panel.add(component(data), BorderLayout.CENTER)
panel.revalidate()
panel.repaint()
}
return panel
}
private fun ref(params: Map<String, String>): AttachmentRef? {
val session = params["sessionId"].takeIfPresent() ?: return null
val message = params["messageId"].takeIfPresent() ?: return null
val part = params["partId"].takeIfPresent() ?: return null
val dir = params["directory"].takeIfPresent() ?: return null
return AttachmentRef(
directory = dir,
sessionId = session,
messageId = message,
partId = part,
attachmentKey = params["attachmentKey"].takeIfPresent(),
filename = params["filename"].takeIfPresent() ?: part,
mime = params["mime"].orEmpty(),
)
}
private val LOG = KiloLog.create(AttachmentEditorKind::class.java)
}
private fun component(data: AttachmentData): JComponent = when (data) {
is AttachmentData.Text -> text(data.text)
is AttachmentData.Image -> JBScrollPane(JBLabel(ImageIcon(data.image), SwingConstants.CENTER))
is AttachmentData.Binary -> metadata(data.name, data.mime, data.size)
is AttachmentData.Missing -> center(KiloBundle.message("session.attachment.missing"))
is AttachmentData.Error -> center(KiloBundle.message("session.attachment.error", data.message))
AttachmentData.Connecting -> connecting()
AttachmentData.ConnectionFailed -> failed()
}
private fun connecting(): JComponent {
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(AnimatedIcon.Default()))
next(JBLabel(KiloBundle.message("session.connection.connecting")))
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
}
private fun failed(): JComponent {
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(KiloBundle.message("session.connection.error.app")))
next(ActionLink(KiloBundle.message("session.connection.retry")) {
service<KiloAppService>().retryAsync()
})
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
}
private fun text(value: String): JComponent {
val area = JBTextArea(value).apply {
isEditable = false
lineWrap = false
border = JBUI.Borders.empty(UiStyle.Gap.sm())
}
return JBScrollPane(area)
}
private fun metadata(name: String, mime: String, size: Int): JComponent {
return Stack.vertical(gap = UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.pad())
next(JBLabel(KiloBundle.message("session.attachment.unsupported", name)))
next(JBLabel(KiloBundle.message("session.attachment.mime", mime.ifBlank { "unknown" })))
next(JBLabel(KiloBundle.message("session.attachment.size", size)))
}
}
private fun center(value: String): JComponent = Centerizer(JBLabel(value), Centerizer.TYPE.BOTH)
@Service(Service.Level.PROJECT)
internal class KiloAttachmentEditorService(
private val project: Project,
private val cs: CoroutineScope,
) {
companion object {
private val LOG = KiloLog.create(KiloAttachmentEditorService::class.java)
}
fun load(ref: AttachmentRef, parent: Disposable, done: (AttachmentData) -> Unit) {
LOG.info("kind=attachment-load phase=start project=${project.name} hash=${project.locationHash} ref=${brief(ref)}")
val disposed = AtomicBoolean(false)
val job = cs.launch {
val app = service<KiloAppService>()
app.connect()
while (!disposed.get()) {
withContext(Dispatchers.Main) {
if (alive(disposed)) {
LOG.info("kind=attachment-load phase=connecting ref=${brief(ref)}")
done(AttachmentData.Connecting)
}
}
val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR }
LOG.info("kind=attachment-load phase=app-state status=${state.status} ref=${brief(ref)}")
if (state.status == KiloAppStatusDto.ERROR) {
withContext(Dispatchers.Main) {
if (alive(disposed)) {
LOG.info("kind=attachment-load phase=connection-failed ref=${brief(ref)}")
done(AttachmentData.ConnectionFailed)
}
}
app.state.first { it.status != KiloAppStatusDto.ERROR }
continue
}
val data = runCatching { fetch(ref) }
.getOrElse {
LOG.warn("kind=attachment-load phase=fetch-error ref=${brief(ref)} message=${it.message}", it)
AttachmentData.Error(it.message ?: it::class.java.simpleName)
}
withContext(Dispatchers.Main) {
if (alive(disposed)) {
LOG.info("kind=attachment-load phase=done data=${describe(data)} ref=${brief(ref)}")
done(data)
}
}
return@launch
}
}
Disposer.register(parent) {
disposed.set(true)
LOG.info("kind=attachment-load phase=dispose ref=${brief(ref)}")
job.cancel()
}
}
private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get()
private suspend fun fetch(ref: AttachmentRef): AttachmentData {
val item = project.service<KiloSessionService>().attachmentPart(
ref.sessionId,
ref.directory,
ref.messageId,
ref.partId,
ref.attachmentKey,
) ?: run {
LOG.info("kind=attachment-fetch result=missing reason=part-not-found session=${ref.sessionId} message=${ref.messageId} part=${ref.partId} key=${ref.attachmentKey ?: "none"}")
return AttachmentData.Missing
}
val mode = if (ref.attachmentKey.isPresent()) "attachmentKey" else "partId"
LOG.info("kind=attachment-fetch phase=matched mode=$mode session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=${item.filename.orEmpty()} mime=${item.mime.orEmpty()} url=${urlInfo(item.url.orEmpty())}")
val data = parseDataUrl(item.url.orEmpty()) ?: run {
LOG.info("kind=attachment-fetch result=missing reason=parse-data-url session=${ref.sessionId} message=${ref.messageId} part=${item.id} url=${urlInfo(item.url.orEmpty())}")
return AttachmentData.Missing
}
val mime = item.mime?.takeIf { it.isNotBlank() } ?: data.mime
val name = item.filename?.takeIf { it.isNotBlank() } ?: ref.filename
LOG.info("kind=attachment-fetch phase=parsed session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=$name dtoMime=${item.mime.orEmpty()} dataMime=${data.mime} mime=$mime bytes=${data.bytes.size}")
if (textual(mime)) return AttachmentData.Text(data.bytes.toString(Charsets.UTF_8))
if (mime.startsWith("image/")) {
return withContext(Dispatchers.IO) {
val image = ImageIO.read(ByteArrayInputStream(data.bytes)) ?: return@withContext AttachmentData.Binary(name, mime, data.bytes.size)
LOG.info("kind=attachment-fetch phase=image session=${ref.sessionId} message=${ref.messageId} part=${item.id} width=${image.width} height=${image.height} bytes=${data.bytes.size}")
AttachmentData.Image(image)
}
}
return AttachmentData.Binary(name, mime, data.bytes.size)
}
}
internal data class AttachmentRef(
val directory: String,
val sessionId: String,
val messageId: String,
val partId: String,
val attachmentKey: String?,
val filename: String,
val mime: String,
)
fun ensureAttachmentEditorKind() {
service<KiloEditorKindRegistry>().register(AttachmentEditorKind)
}
internal fun attachmentParams(
sessionId: String,
messageId: String,
item: FileAttachment,
filename: String,
directory: String,
): Map<String, String> = linkedMapOf(
"directory" to directory,
"sessionId" to sessionId,
"messageId" to messageId,
"partId" to item.id,
"attachmentKey" to attachmentKey(item.id, item.filename.orEmpty(), item.url),
"filename" to filename,
"mime" to item.mime,
)
internal sealed interface AttachmentData {
data class Text(val text: String) : AttachmentData
data class Image(val image: BufferedImage) : AttachmentData
data class Binary(val name: String, val mime: String, val size: Int) : AttachmentData
data object Missing : AttachmentData
data class Error(val message: String) : AttachmentData
data object Connecting : AttachmentData
data object ConnectionFailed : AttachmentData
}
private fun brief(ref: AttachmentRef): String {
return listOf(
"sessionId=${ref.sessionId}",
"messageId=${ref.messageId}",
"partId=${ref.partId}",
"attachmentKey=${ref.attachmentKey ?: ""}",
"filename=${ref.filename}",
"mime=${ref.mime}",
"directory=${ref.directory}",
).joinToString(prefix = "{", postfix = "}")
}
private fun String?.isPresent(): Boolean = !this.isNullOrBlank()
private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() }
private fun describe(data: AttachmentData): String = when (data) {
is AttachmentData.Text -> "text chars=${data.text.length}"
is AttachmentData.Image -> "image width=${data.image.width} height=${data.image.height}"
is AttachmentData.Binary -> "binary name=${data.name} mime=${data.mime} bytes=${data.size}"
is AttachmentData.Error -> "error message=${data.message}"
AttachmentData.Missing -> "missing"
AttachmentData.Connecting -> "connecting"
AttachmentData.ConnectionFailed -> "connection-failed"
}
private fun urlInfo(url: String): String {
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
return "urlScheme=$scheme urlChars=${url.length} embedded=${isEmbeddedAttachment(url)}"
}
private fun attachmentKey(part: String, name: String, url: String): String {
val value = listOf(part, name, url).joinToString("\u0000")
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return bytes.take(16).joinToString("") { "%02x".format(it) }
}
@@ -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)
@@ -57,6 +57,7 @@ class ModelPicker : PickerButton() {
val recommendedIndex: Double? = null,
val free: Boolean = false,
val variants: List<String> = emptyList(),
val attachment: Boolean = false,
) {
val key: String get() = "$provider/$id"
@@ -0,0 +1,42 @@
package ai.kilocode.client.session.ui.prompt
import com.intellij.ide.PasteProvider
import com.intellij.ide.dnd.FileCopyPasteUtil
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.editor.actions.PasteAction
import com.intellij.openapi.util.Key
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
internal fun interface PromptAttachmentPasteHandler {
fun paste(transferable: Transferable)
}
internal val PROMPT_ATTACHMENT_PASTE_HANDLER_KEY: Key<PromptAttachmentPasteHandler> =
Key.create("ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler")
internal class PromptAttachmentPasteProvider : PasteProvider {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun isPastePossible(dataContext: DataContext): Boolean = transferable(dataContext) != null
override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext)
override fun performPaste(dataContext: DataContext) {
val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return
val handler = editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) ?: return
val item = transferable(dataContext) ?: return
handler.paste(item)
}
private fun transferable(dataContext: DataContext): Transferable? {
val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return null
if (editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) == null) return null
val item = dataContext.getData(PasteAction.TRANSFERABLE_PROVIDER)?.produce() ?: return null
if (FileCopyPasteUtil.isFileListFlavorAvailable(item.transferDataFlavors)) return item
if (item.isDataFlavorSupported(DataFlavor.imageFlavor)) return item
return null
}
}
@@ -0,0 +1,89 @@
package ai.kilocode.client.session.ui.prompt
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.PromptAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.ui.UiStyle
import com.intellij.notification.Notification
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.FlowLayout
import javax.swing.JPanel
class PromptAttachmentStrip(
private val project: Project,
private val removed: (PromptAttachment) -> Unit,
) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), UiStyle.Gap.sm())) {
private val chips = LinkedHashMap<String, PromptAttachmentChip>()
init {
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
isVisible = false
}
val count: Int get() = chips.size
@RequiresEdt
fun add(item: PromptAttachment) {
if (chips.containsKey(item.id)) return
val chip = PromptAttachmentChip(project, item, remove = { removed(item) })
chips[item.id] = chip
add(chip)
sync()
}
@RequiresEdt
fun remove(item: PromptAttachment) {
val chip = chips.remove(item.id) ?: return
remove(chip)
sync()
}
@RequiresEdt
fun clear() {
if (chips.isEmpty()) return
chips.clear()
removeAll()
sync()
}
@RequiresEdt
private fun sync() {
isVisible = chips.isNotEmpty()
revalidate()
repaint()
}
}
private class PromptAttachmentChip(
project: Project,
item: PromptAttachment,
remove: () -> Unit,
) : AttachmentCard(
AttachmentCardItem(item.name, item.mime, item.url, item.path),
remove = remove,
open = { open(project, item) },
) {
companion object {
private fun open(project: Project, item: PromptAttachment) {
val path = item.path ?: return
ApplicationManager.getApplication().executeOnPooledThread {
val file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path)
ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater
if (file == null) {
Notification("Kilo Code", KiloBundle.message("prompt.attachment.missing", item.name), NotificationType.WARNING).notify(project)
return@invokeLater
}
FileEditorManager.getInstance(project).openFile(file, true)
}
}
}
}
}
@@ -5,6 +5,8 @@ import ai.kilocode.client.actions.SendPromptAction
import ai.kilocode.client.actions.StopSessionAction
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.model.PromptAttachment
import ai.kilocode.client.session.model.PromptAttachmentExtractor
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
@@ -16,8 +18,12 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.PromptPartDto
import com.intellij.icons.AllIcons
import com.intellij.ide.DataManager
import com.intellij.ide.dnd.DnDEvent
import com.intellij.ide.dnd.DnDSupport
import com.intellij.ide.dnd.FileCopyPasteUtil
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.ActionUiKind
@@ -49,10 +55,13 @@ import java.awt.Cursor
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.Transferable
import java.awt.event.FocusAdapter
import java.awt.event.FocusEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.util.concurrent.Future
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.Icon
@@ -66,7 +75,7 @@ import javax.swing.ScrollPaneConstants
*/
class PromptPanel(
private val project: Project,
private val onSend: (String) -> Unit,
private val onSend: (String, List<PromptPartDto>) -> Unit,
private val onAbort: () -> Unit,
private val onEnhance: (String, (Result<String>) -> Unit) -> Unit,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext {
@@ -88,10 +97,15 @@ class PromptPanel(
var onReset: () -> Unit = {}
var onChange: () -> Unit = {}
var onAutoApproveToggle: (Boolean) -> Unit = {}
var onFileDrag: (Boolean) -> Unit = {}
private var style = SessionEditorStyle.current()
private val shell = PromptShell()
private val attachments = mutableListOf<PromptAttachment>()
private val strip = PromptAttachmentStrip(project) { removeAttachment(it) }
private var bus: MessageBusConnection? = null
private var autoApprove = false
private var attachment = true
private var submitting = false
private val editor = PromptEditorTextField(project, this).apply {
border = JBUI.Borders.empty()
@@ -109,8 +123,11 @@ class PromptPanel(
ed.scrollPane.viewport.background = style.editorScheme.defaultBackground
ed.settings.isUseSoftWraps = true
ed.settings.isAdditionalPageAtBottom = false
ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) })
ed.scrollPane.horizontalScrollBarPolicy =
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
installFileDrop(ed.contentComponent, "editor")
installFileDrop(ed.scrollPane, "scroll")
ed.contentComponent.addFocusListener(object : FocusAdapter() {
override fun focusGained(e: FocusEvent) {
shell.repaint()
@@ -166,7 +183,7 @@ class PromptPanel(
private var request = 0L
override val isSendEnabled: Boolean
get() = ready && !busy && text().isNotEmpty()
get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
override val isStopEnabled: Boolean
get() = busy
@@ -191,6 +208,7 @@ class PromptPanel(
onChange()
}
})
shell.add(strip, BorderLayout.NORTH)
shell.add(editor, BorderLayout.CENTER)
val bar = BorderLayoutPanel().apply {
@@ -213,6 +231,7 @@ class PromptPanel(
bar.add(button)
shell.add(bar, BorderLayout.SOUTH)
add(shell, BorderLayout.CENTER)
installFileDrop(shell, "shell")
syncTooltip()
syncAutoApprove()
syncEnhance()
@@ -224,6 +243,11 @@ class PromptPanel(
if (!value) invalidateEnhancement() else syncEnhance()
}
@RequiresEdt
fun setAttachmentEnabled(value: Boolean) {
attachment = value
}
@RequiresEdt
fun setBusy(value: Boolean) {
busy = value
@@ -270,6 +294,8 @@ class PromptPanel(
internal fun buttonForTest(): JButton = button
internal fun attachmentCountForTest(): Int = attachments.size
internal val defaultFocusedComponent: JComponent get() = editor
@RequiresEdt
@@ -285,9 +311,18 @@ class PromptPanel(
@RequiresEdt
fun clear() {
editor.text = ""
attachments.clear()
strip.clear()
syncEditorHeight()
}
@RequiresEdt
fun addAttachmentForTest(item: PromptAttachment) {
addAttachment(item)
}
internal fun processPasteForTest(transferable: Transferable): Future<*> = processPaste(transferable)
@RequiresEdt
fun focus() {
editor.requestFocusInWindow()
@@ -359,12 +394,134 @@ class PromptPanel(
private fun submit(src: String) {
if (!isSendEnabled) return
val txt = text()
LOG.debug { "${ChatLogSummary.prompt(txt)} src=$src busy=$busy" }
if (txt.isNotEmpty()) {
onSend(txt)
val items = attachments.toList()
submitting = true
ApplicationManager.getApplication().executeOnPooledThread {
try {
val files = items.map { it.part() }
ApplicationManager.getApplication().invokeLater {
submitting = false
if (project.isDisposed) return@invokeLater
LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, files))} src=$src busy=$busy" }
onSend(txt, files)
}
} catch (e: Exception) {
ApplicationManager.getApplication().invokeLater {
submitting = false
if (project.isDisposed) return@invokeLater
LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e)
notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName))
}
}
}
}
@RequiresEdt
private fun addAttachment(item: PromptAttachment) {
if (!attachment && PromptAttachmentExtractor.media(item.mime)) {
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" }
notify(KiloBundle.message("prompt.attachment.unsupported.model"))
return
}
if (attachments.any { it.id == item.id }) {
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=duplicate" }
return
}
attachments += item
strip.add(item)
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} count=${attachments.size}" }
onChange()
}
@RequiresEdt
private fun removeAttachment(item: PromptAttachment) {
if (!attachments.removeIf { it.id == item.id }) return
strip.remove(item)
onChange()
}
private fun promptDto(text: String, files: List<PromptPartDto>) = ai.kilocode.rpc.dto.PromptDto(
parts = buildList {
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
addAll(files)
}
)
internal fun installFileDrop(target: JComponent, area: String) {
LOG.debug { "kind=prompt-dnd install area=$area component=${target.javaClass.name}" }
DnDSupport.createBuilder(target)
.enableAsNativeTarget()
.setTargetChecker { event ->
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) {
onFileDrag(false)
LOG.debug { "kind=prompt-dnd check area=$area accept=false flavor=false" }
return@setTargetChecker true
}
event.setDropPossible(true)
onFileDrag(true)
LOG.debug { "kind=prompt-dnd check area=$area accept=true flavor=true" }
false
}
.setCleanUpOnLeaveCallback {
onFileDrag(false)
}
.setDropHandlerWithResult { event ->
val start = System.nanoTime()
val files = dropFiles(event)
val ms = elapsedMs(start)
LOG.debug { "kind=prompt-dnd drop area=$area files=${files.size} extractMs=$ms queued=${files.isNotEmpty()}" }
onFileDrag(false)
if (files.isEmpty()) return@setDropHandlerWithResult false
processAttachments("prompt-dnd", area, files, null, ms)
true
}
.install()
}
private fun processPaste(transferable: Transferable): Future<*> {
return processAttachments("prompt-paste", "editor", null, transferable, 0)
}
private fun processAttachments(
kind: String,
area: String,
files: List<java.io.File>?,
transferable: Transferable?,
sourceMs: Long,
): Future<*> {
return ApplicationManager.getApplication().executeOnPooledThread {
val start = System.nanoTime()
try {
val list = files ?: transferable?.let { FileCopyPasteUtil.getFileList(it).orEmpty() }.orEmpty()
val image = transferable?.takeIf { list.isEmpty() && it.isDataFlavorSupported(DataFlavor.imageFlavor) }
?.getTransferData(DataFlavor.imageFlavor)
?.let(PromptAttachmentExtractor::image)
val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image)
val ms = elapsedMs(start)
LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
if (items.isEmpty()) return@executeOnPooledThread
ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater
LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
items.forEach(::addAttachment)
}
} catch (e: Exception) {
LOG.warn("kind=$kind extract area=$area failed message=${e.message}", e)
}
}
}
private fun dropFiles(event: DnDEvent): List<java.io.File> {
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList()
return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty()
}
private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000
private fun notify(text: String) {
com.intellij.notification.Notification("Kilo Code", text, com.intellij.notification.NotificationType.WARNING).notify(project)
}
@RequiresEdt
private fun bindKeymap() {
if (bus != null) return
@@ -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
@@ -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)
}
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.model.StepFinish
@@ -15,6 +16,7 @@ import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Graphics
import java.awt.Graphics2D
@@ -38,6 +40,7 @@ class MessageView(
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
private val selection: SessionSelection? = null,
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) },
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
private val repo: String? = null,
private val hover: ((PartView, Boolean) -> Unit)? = null,
@@ -58,6 +61,7 @@ class MessageView(
// so snapshot updates can append only deltas.
private val aliases = LinkedHashMap<String, String>()
private val sources = LinkedHashMap<String, String>()
private var attachments: PromptAttachmentView? = null
private var hidden: ToolCallRef? = null
init {
@@ -112,6 +116,11 @@ class MessageView(
}
val existing = parts[content.id]
if (existing != null) {
if (existing is PromptAttachmentView && content is FileAttachment) {
existing.upsert(content)
refresh()
return
}
if (ViewFactory.shouldReplace(existing, content)) {
replacePart(content, existing)
return
@@ -126,6 +135,10 @@ class MessageView(
}
private fun addPart(content: Content) {
if (content is FileAttachment && role == SessionUiStyle.View.Message.USER_ROLE) {
addAttachment(content)
return
}
if (content is Reasoning) {
val previous = parts.values.lastOrNull()
if (previous is ReasoningView) {
@@ -143,6 +156,19 @@ class MessageView(
add(view)
}
@RequiresEdt
private fun addAttachment(content: FileAttachment) {
val view = attachments ?: PromptAttachmentView(msg.info.id) { openAttachment(msg.info.id, it) }.also {
it.resize = resize
it.hover = hover
it.applyStyle(style)
attachments = it
add(it)
}
view.upsert(content)
parts[content.id] = view
}
private fun updateAlias(content: Reasoning, id: String) {
val view = parts[id] as? ReasoningView ?: return
val prev = sources[content.id].orEmpty()
@@ -184,6 +210,14 @@ class MessageView(
return
}
val view = parts.remove(contentId) ?: return
if (view is PromptAttachmentView) {
view.remove(contentId)
if (!view.isEmpty()) {
refresh()
return
}
attachments = null
}
aliases.values.removeAll { it == contentId }
sources.keys.removeAll { it !in aliases }
detach(view)
@@ -199,6 +233,7 @@ class MessageView(
*/
private fun isHidden(content: Content): Boolean {
if (content !is Tool) return false
if (role == SessionUiStyle.View.Message.USER_ROLE && content.name == "read") return true
if (content.name == "todoread") return true
if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true
val ref = hidden ?: return false
@@ -212,7 +247,7 @@ class MessageView(
* Called only when the hidden ref changes to avoid unnecessary rebuilds.
*/
private fun rebuildParts() {
parts.values.forEach {
parts.values.distinct().forEach {
detach(it)
remove(it)
Disposer.dispose(it)
@@ -220,6 +255,7 @@ class MessageView(
parts.clear()
aliases.clear()
sources.clear()
attachments = null
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
@@ -235,9 +271,9 @@ class MessageView(
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile, openUrl, selection, repo)
ViewFactory.createUser(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
} else {
ViewFactory.create(content, openFile, openUrl, selection, repo)
ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
}
/** Append a streaming delta to the renderer for [contentId]. */
@@ -0,0 +1,133 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import javax.swing.ScrollPaneConstants
class PromptAttachmentView(
messageId: String,
private val openAttachment: (FileAttachment) -> Unit,
) : PartView() {
override val contentId: String = "attachments:$messageId"
private val items = LinkedHashMap<String, FileAttachment>()
private val cards = LinkedHashMap<String, AttachmentCard>()
private val row = Stack.horizontal(gap = UiStyle.Gap.sm())
private val scroll = JBScrollPane(row).apply {
border = null
isOpaque = false
viewport.isOpaque = false
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
}
init {
isOpaque = false
border = JBUI.Borders.empty(
0,
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
add(scroll)
}
fun contains(id: String) = items.containsKey(id)
fun isEmpty() = items.isEmpty()
fun ids(): List<String> = items.keys.toList()
fun scrollPane(): JBScrollPane = scroll
@RequiresEdt
fun upsert(item: FileAttachment) {
val old = items[item.id]
items[item.id] = item
if (old != null && same(old, item)) return
val next = card(item)
val prev = cards.put(item.id, next)
if (prev == null) {
row.next(next)
refresh()
return
}
val at = row.components.indexOfFirst { it === prev }.takeIf { it >= 0 } ?: return refresh()
row.remove(prev)
row.add(next, at)
refresh()
}
@RequiresEdt
fun remove(id: String): Boolean {
val item = items.remove(id) ?: return false
cards.remove(item.id)?.let { row.remove(it) }
refresh()
return true
}
override fun update(content: Content) {
if (content is FileAttachment) upsert(content)
}
override fun getPreferredSize(): Dimension {
val ins = insets
val pref = scroll.preferredSize
return Dimension(0, pref.height + bar() + ins.top + ins.bottom)
}
override fun getMinimumSize() = preferredSize
override fun doLayout() {
val ins = insets
scroll.setBounds(
ins.left,
ins.top,
maxOf(0, width - ins.left - ins.right),
maxOf(0, height - ins.top - ins.bottom),
)
}
override fun dispose() {
row.removeAll()
cards.clear()
items.clear()
}
override fun dumpLabel(): String = "PromptAttachmentView#$contentId[${items.keys.joinToString(",")}]"
private fun refresh() {
revalidate()
repaint()
}
private fun card(item: FileAttachment) = AttachmentCard(
AttachmentCardItem(name(item), item.mime, item.url),
open = { openAttachment(item) },
)
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename
private fun bar() = scroll.horizontalScrollBar.preferredSize.height
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
?: tail(item.url).takeIf { it.isNotBlank() }
?: "attachment"
private fun tail(value: String): String {
val clean = value.trimEnd('/', '\\')
val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
if (index < 0) return clean
return clean.substring(index + 1)
}
}
@@ -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()
@@ -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)
}
/**
@@ -14,6 +14,7 @@ import ai.kilocode.client.ui.layout.HAlign
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.cli.KiloCliParser
import ai.kilocode.log.KiloLog
import com.intellij.openapi.Disposable
import com.intellij.openapi.editor.EditorFactory
@@ -539,19 +540,11 @@ internal data class Target(
internal fun target(tool: Tool): Target? {
val out = output(tool)
if (out.isBlank()) return null
val path = tag(out, "path") ?: return null
val type = tag(out, "type") ?: return null
val path = KiloCliParser.tag(out, "path") ?: return null
val type = KiloCliParser.tag(out, "type") ?: return null
return Target(path, type.lowercase())
}
private fun tag(text: String, name: String): String? =
Regex("<$name>\\s*([\\s\\S]*?)\\s*</$name>")
.find(text)
?.groupValues
?.getOrNull(1)
?.trim()
?.takeIf { it.isNotBlank() }
private fun shellTitle(tool: Tool): String =
tool.input["description"]?.takeIf { it.isNotBlank() }
?: tool.metadata["description"]?.takeIf { it.isNotBlank() }
@@ -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
}
@@ -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
}
@@ -0,0 +1,22 @@
package ai.kilocode.client.vfs
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import java.util.concurrent.ConcurrentHashMap
@Service(Service.Level.APP)
class KiloEditorKindRegistry {
private val kinds = ConcurrentHashMap<String, KiloEditorKind>()
fun register(kind: KiloEditorKind) {
kinds[kind.id] = kind
service<KiloVirtualFileKindRegistry>().register(kind)
}
fun unregister(id: String) {
kinds.remove(id)
service<KiloVirtualFileKindRegistry>().unregister(id)
}
fun get(id: String): KiloEditorKind? = kinds[id]
}
@@ -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()
}
}
@@ -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
}
@@ -0,0 +1,46 @@
package ai.kilocode.client.vfs
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.FileEditorPolicy
import com.intellij.openapi.fileEditor.FileEditorProvider
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.VirtualFile
class KiloFileEditorProvider : FileEditorProvider, DumbAware {
override fun accept(project: Project, file: VirtualFile): Boolean {
ensureAttachmentEditorKind()
val path = path(file) ?: return false
return service<KiloEditorKindRegistry>().get(path.kind) != null
}
override fun acceptRequiresReadAction(): Boolean = false
override fun createEditor(project: Project, file: VirtualFile): FileEditor {
ensureAttachmentEditorKind()
val path = path(file) ?: error("Invalid Kilo virtual file: ${file.path}")
val kilo = file as? KiloVirtualFile ?: KiloVirtualFile(path)
val kind = service<KiloEditorKindRegistry>().get(kilo.path.kind) ?: error("Unknown Kilo editor kind: ${kilo.path.kind}")
return KiloFileEditor(project, file, kilo, kind)
}
override fun disposeEditor(editor: FileEditor) {
Disposer.dispose(editor)
}
override fun getEditorTypeId(): String = EDITOR_TYPE_ID
override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_OTHER_EDITORS
companion object {
const val EDITOR_TYPE_ID = "KiloVfsEditor"
private fun path(file: VirtualFile): KiloPath? {
if (file is KiloVirtualFile) return file.path
if (file.fileSystem.protocol != KiloVirtualFileSystem.PROTOCOL && !file.url.startsWith("${KiloVirtualFileSystem.PROTOCOL}://")) return null
return KiloVirtualFileSystem.decode(file.path) ?: KiloVirtualFileSystem.decode(file.url)
}
}
}
@@ -0,0 +1,16 @@
package ai.kilocode.client.vfs
import kotlinx.serialization.Serializable
@Serializable
data class KiloPath(
val kind: String,
val params: Map<String, String> = emptyMap(),
) {
fun canonical(): KiloPath = copy(params = canonicalParams(params))
}
internal fun canonicalParams(params: Map<String, String>): Map<String, String> {
if (params.size < 2) return params
return params.toSortedMap()
}
@@ -0,0 +1,40 @@
package ai.kilocode.client.vfs
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorProvider
import com.intellij.openapi.project.Project
import com.intellij.util.concurrency.annotations.RequiresEdt
@Service(Service.Level.PROJECT)
class KiloVfsManager(private val project: Project) {
@RequiresEdt
fun open(kind: String, params: Map<String, String> = emptyMap(), focus: Boolean = true): Boolean {
val file = file(kind, params) ?: return false
if (ApplicationManager.getApplication().isUnitTestMode) {
file.putUserData(FileEditorProvider.KEY, KiloFileEditorProvider())
}
FileEditorManager.getInstance(project).openFile(file, focus)
return true
}
@RequiresEdt
fun close(kind: String, params: Map<String, String> = emptyMap()) {
val file = file(kind, params) ?: return
FileEditorManager.getInstance(project).closeFile(file)
KiloVirtualFileSystem.getInstance().release(file.path)
}
@RequiresEdt
fun updatePresentation(kind: String, params: Map<String, String> = emptyMap()) {
val file = file(kind, params) ?: return
FileEditorManager.getInstance(project).updateFilePresentation(file)
}
private fun file(kind: String, params: Map<String, String>): KiloVirtualFile? {
val path = KiloPath(kind, params)
val fs = KiloVirtualFileSystem.getInstance()
return fs.refreshAndFindFileByPath(fs.getPath(path)) as? KiloVirtualFile
}
}
@@ -0,0 +1,58 @@
@file:Suppress("LeakingThis")
package ai.kilocode.client.vfs
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManagerKeys
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.fileTypes.FileTypes
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFilePathWrapper
import com.intellij.openapi.vfs.VirtualFileWithoutContent
import java.io.InputStream
import java.io.OutputStream
class KiloVirtualFile(
val path: KiloPath,
) : VirtualFile(),
VirtualFileWithoutContent,
VirtualFilePathWrapper {
init {
putUserData(FileEditorManagerKeys.FORBID_TAB_SPLIT, true)
}
override fun getFileSystem(): KiloVirtualFileSystem = KiloVirtualFileSystem.getInstance()
override fun getFileType(): FileType = FileTypes.UNKNOWN
override fun getPath(): String = fileSystem.getPath(path)
override fun getUrl(): String = "${fileSystem.protocol}://$path"
override fun getName(): String = kind()?.title(path.params) ?: path.kind
override fun getPresentableName(): String = name
override fun getPresentablePath(): String = kind()?.presentablePath(path.params) ?: name
override fun enforcePresentableName(): Boolean = true
override fun isValid(): Boolean = kind()?.isValid(path.params) == true
override fun isWritable(): Boolean = false
override fun isDirectory(): Boolean = false
override fun getParent(): VirtualFile? = null
override fun getChildren(): Array<VirtualFile> = emptyArray()
override fun getLength(): Long = 0
override fun getTimeStamp(): Long = 0
override fun getModificationStamp(): Long = 0
override fun refresh(asynchronous: Boolean, recursive: Boolean, postRunnable: Runnable?) {
postRunnable?.run()
}
override fun contentsToByteArray(): ByteArray = throw UnsupportedOperationException()
override fun getInputStream(): InputStream = throw UnsupportedOperationException()
override fun getOutputStream(requestor: Any?, newModificationStamp: Long, newTimeStamp: Long): OutputStream =
throw UnsupportedOperationException()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is KiloVirtualFile) return false
return path == other.path
}
override fun hashCode(): Int = path.hashCode()
private fun kind(): KiloVirtualFileKind? = service<KiloVirtualFileKindRegistry>().get(path.kind)
}
@@ -0,0 +1,15 @@
package ai.kilocode.client.vfs
import javax.swing.Icon
interface KiloVirtualFileKind {
val id: String
fun title(params: Map<String, String>): String
fun icon(params: Map<String, String>): Icon? = null
fun presentablePath(params: Map<String, String>): String = title(params)
fun isValid(params: Map<String, String>): Boolean = true
}
@@ -0,0 +1,19 @@
package ai.kilocode.client.vfs
import com.intellij.openapi.components.Service
import java.util.concurrent.ConcurrentHashMap
@Service(Service.Level.APP)
class KiloVirtualFileKindRegistry {
private val kinds = ConcurrentHashMap<String, KiloVirtualFileKind>()
fun register(kind: KiloVirtualFileKind) {
kinds[kind.id] = kind
}
fun unregister(id: String) {
kinds.remove(id)
}
fun get(id: String): KiloVirtualFileKind? = kinds[id]
}
@@ -0,0 +1,85 @@
package ai.kilocode.client.vfs
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.vfs.NonPhysicalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.VirtualFileListener
import com.intellij.openapi.vfs.VirtualFilePathWrapper
import com.intellij.openapi.vfs.VirtualFileSystem
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.json.Json
class KiloVirtualFileSystem : VirtualFileSystem(), NonPhysicalFileSystem {
private val files = ConcurrentHashMap<KiloPath, KiloVirtualFile>()
fun getPath(path: KiloPath): String = json.encodeToString(KiloPath.serializer(), path.canonical())
fun findOrCreateFile(path: KiloPath): VirtualFile? {
service<KiloVirtualFileKindRegistry>().get(path.kind) ?: return null
return files.computeIfAbsent(path.canonical()) { KiloVirtualFile(it) }
}
fun release(path: KiloPath) {
files.remove(path.canonical())
}
override fun findFileByPath(path: String): VirtualFile? {
val parsed = decode(path) ?: return null
return findOrCreateFile(parsed)
}
override fun refreshAndFindFileByPath(path: String): VirtualFile? = findFileByPath(path)
override fun extractPresentableUrl(path: String): String {
return (refreshAndFindFileByPath(path) as? VirtualFilePathWrapper)?.presentablePath ?: path
}
override fun refresh(asynchronous: Boolean) {}
override fun getProtocol(): String = PROTOCOL
override fun addVirtualFileListener(listener: VirtualFileListener) {}
override fun removeVirtualFileListener(listener: VirtualFileListener) {}
override fun isReadOnly(): Boolean = true
override fun deleteFile(requestor: Any?, file: VirtualFile) = unsupported()
override fun moveFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile) = unsupported()
override fun renameFile(requestor: Any?, file: VirtualFile, newName: String) = unsupported()
override fun createChildFile(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported()
override fun createChildDirectory(requestor: Any?, file: VirtualFile, name: String): VirtualFile = unsupported()
override fun copyFile(requestor: Any?, file: VirtualFile, newParent: VirtualFile, copyName: String): VirtualFile = unsupported()
private fun unsupported(): Nothing = throw UnsupportedOperationException("Kilo virtual files are read-only")
companion object {
const val PROTOCOL = "kilo"
private val json = Json
private val log = logger<KiloVirtualFileSystem>()
private val local = KiloVirtualFileSystem()
fun getInstance(): KiloVirtualFileSystem = local
fun decode(path: String): KiloPath? {
return try {
val raw = raw(path) ?: return null
json.decodeFromString(KiloPath.serializer(), raw).canonical()
} catch (err: Exception) {
log.warn("Cannot deserialize $path", err)
null
}
}
private fun raw(path: String): String? {
if (path.startsWith("{")) return path
if (!path.startsWith("$PROTOCOL://")) return null
val raw = path.substringAfter("://")
if (raw.startsWith("{")) return raw
if (!raw.startsWith("%7B", ignoreCase = true)) return null
return URLDecoder.decode(raw, StandardCharsets.UTF_8)
}
}
}
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#C84242"/>
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#DB5860"/>
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#6C707E"/>
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#FFFFFF" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#CED0D6"/>
<path d="M5.5 5.5L10.5 10.5M10.5 5.5L5.5 10.5" stroke="#1E1F22" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -9,6 +9,7 @@
<extensions defaultExtensionNs="com.intellij">
<applicationService serviceImplementation="ai.kilocode.client.migration.KiloMigrationService"/>
<applicationService serviceImplementation="ai.kilocode.client.autocomplete.KiloAutocompleteSettingsService"/>
<customPasteProvider implementation="ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider"/>
<notificationGroup id="Kilo Code"
displayType="BALLOON"
@@ -20,6 +21,8 @@
icon="/icons/kilo.svg"
factoryClass="ai.kilocode.client.KiloToolWindowFactory"/>
<fileEditorProvider id="KiloVfsEditor" implementation="ai.kilocode.client.vfs.KiloFileEditorProvider"/>
<applicationConfigurable
parentId="tools"
id="ai.kilocode.jetbrains.settings"
@@ -20,6 +20,8 @@ feedback.dialog.discord=Join our Discord community
feedback.dialog.support=Customer Support
session.scroll.bottom=Scroll to bottom
session.scroll.question=Scroll to question
session.drop.files.title=Drop files here
session.drop.files.subtitle=to add them to the prompt
session.tab.new=New Session
session.tab.untitled=Untitled Session
@@ -135,6 +137,21 @@ prompt.placeholder.with.newline=Type a message... ({0} for new line)
prompt.button.send=Send
prompt.button.stop=Stop
prompt.button.send.tooltip.stop=To stop, press {0}
prompt.attachment.remove=Remove {0}
prompt.attachment.open=Open {0}
prompt.attachment.tooltip=Name: {0}\nType: {1}\nLocation: {2}
prompt.attachment.embedded=Embedded content
prompt.attachment.unsupported.model=The selected model does not support image or PDF attachments.
prompt.attachment.missing=Attachment no longer exists: {0}
prompt.attachment.send.failed=Failed to send attachment: {0}
session.attachment.title=Attachment
session.attachment.path=Kilo / Attachments / {0} / {1}
session.attachment.loading=Loading attachment...
session.attachment.missing=Attachment not found
session.attachment.unsupported=Cannot preview {0}
session.attachment.mime=Type: {0}
session.attachment.size=Size: {0} bytes
session.attachment.error=Failed to load attachment: {0}
prompt.action.enhance=Enhance prompt
prompt.action.enhance.loading=Enhancing prompt...
prompt.action.enhance.description=The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.
@@ -10,6 +10,7 @@ import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.empty.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.SessionDropOverlay
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionMessageListPanel
@@ -56,6 +57,59 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertEquals(listOf(connection, prompt), stack.components.toList())
}
fun `test drop overlay is attached under root overlay layer`() {
val root = find<SessionRootPanel>(ui)
val drop = find<SessionDropOverlay>(ui)
assertSame(root.overlay, drop.parent)
assertTrue(drop.isVisible)
assertFalse(drop.contains(1, 1))
assertFalse(root.blocker.components.contains(drop))
}
fun `test drop overlay is visual only and not native file drop target`() {
val drop = find<SessionDropOverlay>(ui)
assertNull(drop.dropTarget)
}
fun `test prompt file drag leave does not immediately hide drop overlay`() {
val prompt = find<PromptPanel>(ui)
val drop = find<SessionDropOverlay>(ui)
val card = dropCard(drop)
layout()
prompt.onFileDrag(true)
assertFalse(drop.contains(1, 1))
assertTrue(card.isVisible)
prompt.onFileDrag(false)
assertFalse(drop.contains(1, 1))
assertTrue(card.isVisible)
prompt.onFileDrag(true)
drop.setActive(false)
}
fun `test drop overlay covers full session after layout`() {
val root = find<SessionRootPanel>(ui)
val drop = find<SessionDropOverlay>(ui)
layout()
assertEquals(java.awt.Rectangle(0, 0, root.overlay.width, root.overlay.height), drop.bounds)
}
fun `test drop overlay is above account and scroll overlays`() {
val root = find<SessionRootPanel>(ui)
val drop = find<SessionDropOverlay>(ui)
val account = find<SessionAccountOverlay>(ui)
val jump = jumpButton()
assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(account))
assertTrue(root.overlay.getComponentZOrder(drop) < root.overlay.getComponentZOrder(jump))
}
fun `test active views are children of message list panel`() {
ui = newUi(id = "ses_test")
settle()
@@ -465,4 +519,11 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertEquals(top, overlay.y)
assertEquals(root.overlay.width - overlay.width - right, overlay.x)
}
private fun dropCard(drop: SessionDropOverlay) = drop.components
.single()
.let { it as javax.swing.JComponent }
.components
.single()
.let { it as javax.swing.JComponent }
}
@@ -133,6 +133,27 @@ class SessionModelTest : BasePlatformTestCase() {
assertTrue(events.single() is SessionModelEvent.ContentUpdated)
}
fun `test updateContent ignores new empty text content`() {
model.addMessage(msg("m1", "user"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = " "))
assertNull(model.message("m1")!!.parts["p1"])
assertTrue(events.isEmpty())
}
fun `test updateContent removes existing text when it becomes empty`() {
model.addMessage(msg("m1", "user"))
model.updateContent("m1", part("p1", "m1", "text", text = "visible"))
events.clear()
model.updateContent("m1", part("p1", "m1", "text", text = ""))
assertNull(model.message("m1")!!.parts["p1"])
assertEquals("ContentRemoved m1/p1", events.single().toString())
}
fun `test updateContent reasoning creates Reasoning content`() {
model.addMessage(msg("m1", "assistant"))
@@ -153,6 +174,31 @@ class SessionModelTest : BasePlatformTestCase() {
assertTrue(p.done)
}
fun `test updateContent file creates attachment content and updates metadata`() {
model.addMessage(msg("m1", "user"))
events.clear()
model.updateContent("m1", filePart("f1", "m1", "image/png", "file:///tmp/a.png", "a.png"))
val file = model.message("m1")!!.parts["f1"] as FileAttachment
assertEquals("image/png", file.mime)
assertEquals("file:///tmp/a.png", file.url)
assertEquals("a.png", file.filename)
assertEquals("ContentAdded m1/f1", events.single().toString())
model.updateContent("m1", filePart("f1", "m1", "application/pdf", "file:///tmp/b.pdf", "b.pdf"))
assertSame(file, model.message("m1")!!.parts["f1"])
assertEquals("application/pdf", file.mime)
assertEquals("file:///tmp/b.pdf", file.url)
assertEquals("b.pdf", file.filename)
assertTrue(events.any { it.toString() == "ContentUpdated m1/f1" })
assertModel("""
user#m1
file#f1 application/pdf b.pdf
""")
}
fun `test updateContent tool creates Tool content and tracks state`() {
model.addMessage(msg("m1", "assistant"))
@@ -848,12 +894,18 @@ class SessionModelTest : BasePlatformTestCase() {
tokens: TokensDto? = null,
todos: List<TodoDto> = emptyList(),
todoView: TodoViewDto? = null,
mime: String? = null,
url: String? = null,
filename: String? = null,
) = PartDto(
id = id,
sessionID = "ses",
messageID = mid,
type = type,
text = text,
mime = mime,
url = url,
filename = filename,
tool = tool,
state = state,
title = title,
@@ -869,6 +921,15 @@ class SessionModelTest : BasePlatformTestCase() {
tokens = tokens,
)
private fun filePart(id: String, mid: String, mime: String, url: String, filename: String) = part(
id = id,
mid = mid,
type = "file",
mime = mime,
url = url,
filename = filename,
)
private fun question(id: String) = Question(
id = id,
items = listOf(
@@ -2,22 +2,49 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.PromptAttachment
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.ui.prompt.PROMPT_ATTACHMENT_PASTE_HANDLER_KEY
import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler
import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.PromptPanel
import com.intellij.icons.AllIcons
import com.intellij.notification.Notification
import com.intellij.notification.Notifications
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.actions.PasteAction
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.util.Producer
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CancellationException
import java.awt.Container
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
import java.awt.event.MouseEvent
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Base64
import javax.imageio.ImageIO
import javax.swing.JButton
import javax.swing.ImageIcon
import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
@@ -25,7 +52,7 @@ class PromptPanelTest : BasePlatformTestCase() {
fun `test prompt input uses editor font settings`() {
val style = SessionEditorStyle.current()
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val font = panel.inputFont()
assertEquals(style.editorFamily, font.name)
@@ -34,13 +61,13 @@ class PromptPanelTest : BasePlatformTestCase() {
fun `test prompt input uses editor background`() {
val style = SessionEditorStyle.current()
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background)
}
fun `test applyStyle updates prompt input and height`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val style = SessionEditorStyle.create(family = "Courier New", size = 26)
panel.applyStyle(style)
@@ -51,7 +78,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test prompt editor grows when lines are added`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val editor = panel.defaultFocusedComponent as EditorTextField
val min = editor.preferredSize.height
@@ -61,7 +88,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test prompt editor shrinks when lines are removed`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val editor = panel.defaultFocusedComponent as EditorTextField
val min = editor.preferredSize.height
@@ -74,7 +101,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test prompt editor shrinks after clear`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val editor = panel.defaultFocusedComponent as EditorTextField
val min = editor.preferredSize.height
@@ -86,8 +113,124 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals(min, editor.preferredSize.height)
}
fun `test attachment only prompt can send`() {
var sent = false
val panel = PromptPanel(project, { text, files ->
sent = text.isBlank() && files.single().url == "file:///tmp/a.png"
}, {}, { _, _ -> })
panel.setReady(true)
panel.addAttachmentForTest(PromptAttachment("a", "a.png", "image/png", "file:///tmp/a.png"))
panel.send()
waitForSend { sent }
assertTrue(sent)
}
fun `test clear removes attachments`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
panel.addAttachmentForTest(PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt"))
assertEquals(1, panel.attachmentCountForTest())
panel.clear()
assertEquals(0, panel.attachmentCountForTest())
}
fun `test removed attachment can be added again`() {
val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a.txt")
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
panel.addAttachmentForTest(item)
attachmentRemoveButton(panel, item).doClick()
panel.addAttachmentForTest(item)
assertEquals(1, panel.attachmentCountForTest())
}
fun `test attachment card is compact icon only with tooltip metadata and hover remove`() {
val item = PromptAttachment("a", "a.txt", "text/plain", "file:///tmp/a%20b.txt")
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
panel.addAttachmentForTest(item)
val button = attachmentRemoveButton(panel, item)
val card = attachmentCard(panel)
assertFalse(button.isVisible)
assertTrue(card.toolTipText.contains("a.txt"))
assertTrue(card.toolTipText.contains("text/plain"))
assertTrue(card.toolTipText.contains("/tmp/a b.txt"))
assertFalse(card.toolTipText.contains("file:///"))
assertTrue(card.toolTipText.startsWith("<html>"))
assertTrue(card.toolTipText.contains("Name: a.txt<br>Type: text/plain<br>Location: /tmp/a b.txt"))
assertFalse(labels(card).any { it.text == "a.txt" || it.text == "text/plain" || it.text == "/tmp/a b.txt" })
assertTrue(components(card).filterIsInstance<javax.swing.JComponent>().any { it !== button && it.toolTipText == card.toolTipText })
assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_WIDTH), card.preferredSize.width)
assertEquals(JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT), card.preferredSize.height)
assertEquals(0, card.getComponentZOrder(button))
val label = labels(card).first()
label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false))
assertTrue(button.isVisible)
val icon = button.icon
button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false))
assertNotSame(icon, button.icon)
button.dispatchEvent(MouseEvent(button, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, 1, 1, 0, false))
assertSame(icon, button.icon)
}
fun `test attachment tooltip hides embedded binary content`() {
val item = PromptAttachment("a", "a.png", "image/png", "data:image/png;base64,aGVsbG8=")
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
panel.addAttachmentForTest(item)
val tip = attachmentCard(panel).toolTipText
assertTrue(tip.contains("Name: a.png"))
assertTrue(tip.contains("Type: image/png"))
assertTrue(tip.contains("Location: ${KiloBundle.message("prompt.attachment.embedded")}"))
assertFalse(tip.contains("data:image/png"))
assertFalse(tip.contains("base64"))
assertFalse(tip.contains("aGVsbG8="))
}
fun `test attachment child click opens item`() {
var opened = false
val card = AttachmentCard(
AttachmentCardItem("a.txt", "text/plain", "file:///tmp/a.txt"),
open = { opened = true },
)
val label = labels(card).first()
label.dispatchEvent(MouseEvent(label, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
assertTrue(opened)
}
fun `test attachment card previews embedded image data`() {
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
val out = ByteArrayOutputStream()
ImageIO.write(image, "png", out)
val card = AttachmentCard(
AttachmentCardItem("a.png", "image/png", "data:image/png;base64,${Base64.getEncoder().encodeToString(out.toByteArray())}"),
)
card.addNotify()
repeat(20) {
UIUtil.dispatchAllInvocationEvents()
if (labels(card).any { it.icon is ImageIcon }) return@repeat
Thread.sleep(20)
}
assertTrue(labels(card).any { it.icon is ImageIcon })
}
fun `test reasoning picker hides when variants are empty`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
panel.reasoning.setItems(emptyList())
@@ -95,7 +238,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test reasoning picker shows selected variant`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
panel.reasoning.setItems(listOf(ReasoningPicker.Item("low", "Low"), ReasoningPicker.Item("high", "High")), "high")
@@ -119,7 +262,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test reset visibility can be toggled`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
panel.setResetVisible(true)
@@ -127,7 +270,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test prompt editor exposes send context`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val sink = TestSink()
(panel.defaultFocusedComponent as UiDataProvider).uiDataSnapshot(sink)
@@ -136,7 +279,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test prompt button exposes send context`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val sink = TestSink()
(panel.buttonForTest() as UiDataProvider).uiDataSnapshot(sink)
@@ -144,8 +287,106 @@ class PromptPanelTest : BasePlatformTestCase() {
assertSame(panel, sink.send)
}
fun `test prompt paste provider invokes registered handler`() {
val editor = createEditor()
val item = FileListTransferable(listOf(File.createTempFile("kilo-paste", ".txt")))
var seen: Transferable? = null
editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { seen = it })
try {
PromptAttachmentPasteProvider().performPaste(pasteContext(editor, item))
assertSame(item, seen)
} finally {
EditorFactory.getInstance().releaseEditor(editor)
}
}
fun `test file list paste adds attachment`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
val file = File.createTempFile("kilo-paste", ".txt")
file.writeText("hello")
PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file))))
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, panel.attachmentCountForTest())
}
fun `test frontend file attachment defers data url encoding until send`() {
val file = File.createTempFile("kilo-paste", ".txt")
file.writeText("hello")
val item = ai.kilocode.client.session.model.PromptAttachmentExtractor.files(listOf(file)).single()
assertTrue(item.url.startsWith("file://"))
assertTrue(item.part().url.orEmpty().startsWith("data:text/plain;base64,"))
}
fun `test pasted frontend file sends data url payload`() {
var sent: ai.kilocode.rpc.dto.PromptPartDto? = null
val panel = PromptPanel(project, { _, files -> sent = files.single() }, {}, { _, _ -> })
val file = File.createTempFile("kilo-paste", ".txt")
file.writeText("hello")
panel.setReady(true)
PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileListTransferable(listOf(file))))
UIUtil.dispatchAllInvocationEvents()
panel.send()
waitForSend { sent != null }
val item = sent!!
assertEquals("text/plain", item.mime)
assertTrue(item.url.orEmpty().startsWith("data:text/plain;base64,"))
assertFalse(item.url.orEmpty().startsWith("file://"))
}
fun `test raw image paste adds attachment`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(image)))
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, panel.attachmentCountForTest())
}
fun `test file paste ignores companion image flavor`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
val file = File.createTempFile("kilo-paste", ".png")
file.writeBytes(byteArrayOf())
val image = BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB)
PlatformTestUtil.waitForFuture(panel.processPasteForTest(FileImageTransferable(listOf(file), image)))
UIUtil.dispatchAllInvocationEvents()
assertEquals(1, panel.attachmentCountForTest())
}
fun `test normal text paste is not intercepted`() {
val editor = createEditor()
val provider = PromptAttachmentPasteProvider()
editor.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler {})
try {
assertFalse(provider.isPasteEnabled(pasteContext(editor, StringSelection("hello"))))
} finally {
EditorFactory.getInstance().releaseEditor(editor)
}
}
fun `test disabled media model blocks pasted image`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
panel.setAttachmentEnabled(false)
PlatformTestUtil.waitForFuture(panel.processPasteForTest(ImageTransferable(BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB))))
UIUtil.dispatchAllInvocationEvents()
assertEquals(0, panel.attachmentCountForTest())
}
fun `test prompt button switches between send and stop state`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
assertEquals(KeymapUtil.createTooltipText("Send", "Kilo.SendPrompt"), panel.buttonForTest().toolTipText)
assertFalse(panel.isStopEnabled)
@@ -157,7 +398,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test auto approve button toggles and updates tooltip`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val button = autoApproveButton(panel)
var seen: Boolean? = null
panel.onAutoApproveToggle = { seen = it }
@@ -188,7 +429,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test auto approve and enhance buttons sit next to send button`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val auto = autoApproveButton(panel)
val enhance = enhanceButton(panel)
val send = panel.buttonForTest()
@@ -202,7 +443,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test enhance button follows connection and busy state`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val enhance = enhanceButton(panel)
assertFalse(enhance.isEnabled)
@@ -220,7 +461,7 @@ class PromptPanelTest : BasePlatformTestCase() {
fun `test enhance button rewrites active draft`() {
var seen: String? = null
var complete: ((Result<String>) -> Unit)? = null
val panel = PromptPanel(project, {}, {}, { text, done ->
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { text, done ->
seen = text
complete = done
})
@@ -249,7 +490,7 @@ class PromptPanelTest : BasePlatformTestCase() {
fun `test edit while enhancing ignores stale completion`() {
var complete: ((Result<String>) -> Unit)? = null
val panel = PromptPanel(project, {}, {}, { _, done -> complete = done })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done })
val editor = panel.defaultFocusedComponent as EditorTextField
val enhance = enhanceButton(panel)
panel.setReady(true)
@@ -273,7 +514,7 @@ class PromptPanelTest : BasePlatformTestCase() {
ApplicationManager.getApplication().messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener)
project.messageBus.connect(testRootDisposable).subscribe(Notifications.TOPIC, listener)
var complete: ((Result<String>) -> Unit)? = null
val panel = PromptPanel(project, {}, {}, { _, done -> complete = done })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, done -> complete = done })
val editor = panel.defaultFocusedComponent as EditorTextField
val enhance = enhanceButton(panel)
panel.setReady(true)
@@ -290,7 +531,7 @@ class PromptPanelTest : BasePlatformTestCase() {
fun `test empty enhancement inserts explanation without request`() {
var requests = 0
val panel = PromptPanel(project, {}, {}, { _, _ -> requests++ })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> requests++ })
val editor = panel.defaultFocusedComponent as EditorTextField
panel.setReady(true)
@@ -301,7 +542,7 @@ class PromptPanelTest : BasePlatformTestCase() {
}
fun `test pickers belong to rounded shell`() {
val panel = PromptPanel(project, {}, {}, { _, _ -> })
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val shell = panel.shellForTest()
assertTrue(SwingUtilities.isDescendingFrom(panel.mode, shell))
@@ -319,6 +560,25 @@ class PromptPanelTest : BasePlatformTestCase() {
}
}
private fun attachmentRemoveButton(panel: PromptPanel, item: PromptAttachment): JButton {
val name = KiloBundle.message("prompt.attachment.remove", item.name)
return buttons(panel).first { it.accessibleContext.accessibleName == name }
}
private fun attachmentCard(root: java.awt.Component): AttachmentCard {
fun visit(node: java.awt.Component): AttachmentCard? {
if (node is AttachmentCard) return node
if (node is Container) {
for (child in node.components) {
val card = visit(child)
if (card != null) return card
}
}
return null
}
return visit(root)!!
}
private fun enhanceButton(panel: PromptPanel): JButton {
val name = KiloBundle.message("prompt.action.enhance")
return buttons(panel).first { it.accessibleContext.accessibleName == name }
@@ -334,6 +594,83 @@ class PromptPanelTest : BasePlatformTestCase() {
return out
}
private fun labels(root: java.awt.Component): List<JBLabel> {
return components(root).filterIsInstance<JBLabel>()
}
private fun components(root: java.awt.Component): List<java.awt.Component> {
val out = mutableListOf<java.awt.Component>()
fun visit(node: java.awt.Component) {
out.add(node)
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
private fun createEditor(): Editor {
val factory = EditorFactory.getInstance()
return factory.createEditor(factory.createDocument(""), project)
}
private fun waitForSend(done: () -> Boolean) {
repeat(50) {
UIUtil.dispatchAllInvocationEvents()
if (done()) return
Thread.sleep(20)
}
}
private fun pasteContext(editor: Editor, item: Transferable) = DataContext { id ->
when (id) {
CommonDataKeys.EDITOR.name -> editor
PasteAction.TRANSFERABLE_PROVIDER.name -> Producer { item }
else -> null
}
}
private class FileListTransferable(private val files: List<File>) : Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.javaFileListFlavor)
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.javaFileListFlavor
override fun getTransferData(flavor: DataFlavor): Any {
if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor)
return files
}
}
private class ImageTransferable(private val image: BufferedImage) : Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(DataFlavor.imageFlavor)
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor
override fun getTransferData(flavor: DataFlavor): Any {
if (!isDataFlavorSupported(flavor)) throw java.awt.datatransfer.UnsupportedFlavorException(flavor)
return image
}
}
private class FileImageTransferable(
private val files: List<File>,
private val image: BufferedImage,
) : Transferable {
override fun getTransferDataFlavors(): Array<DataFlavor> = arrayOf(
DataFlavor.javaFileListFlavor,
DataFlavor.imageFlavor,
)
override fun isDataFlavorSupported(flavor: DataFlavor): Boolean {
return flavor == DataFlavor.javaFileListFlavor || flavor == DataFlavor.imageFlavor
}
override fun getTransferData(flavor: DataFlavor): Any {
if (flavor == DataFlavor.javaFileListFlavor) return files
if (flavor == DataFlavor.imageFlavor) return image
throw java.awt.datatransfer.UnsupportedFlavorException(flavor)
}
}
private class TestSink : DataSink {
var send: Any? = null
@@ -1,7 +1,10 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Dimension
import java.awt.Rectangle
@@ -71,6 +74,63 @@ class SessionRootPanelTest : BasePlatformTestCase() {
assertTrue(child.laid)
}
fun `test drop overlay starts visible but never captures hit tests`() {
val drop = SessionDropOverlay().apply {
setSize(200, 100)
}
val card = dropCard(drop)
assertTrue(drop.isVisible)
assertFalse(drop.contains(50, 50))
assertFalse(card.isVisible)
drop.setActive(true)
assertFalse(drop.contains(50, 50))
assertTrue(card.isVisible)
drop.setActive(false)
assertFalse(drop.contains(50, 50))
assertFalse(card.isVisible)
}
fun `test drop overlay can fill root overlay bounds`() {
val root = SessionRootPanel().apply {
setSize(400, 260)
}
val drop = SessionDropOverlay()
root.addOverlay(drop) { pane, _ ->
Rectangle(0, 0, pane.width, pane.height)
}
root.doLayout()
assertEquals(Rectangle(0, 0, 400, 260), drop.bounds)
}
fun `test drop overlay labels use platform heading fonts`() {
val drop = SessionDropOverlay()
val labels = dropLabels(drop)
assertEquals("Drop files here", labels[0].text)
assertEquals(JBFont.h0(), labels[0].font)
assertEquals("to add them to the prompt", labels[1].text)
assertEquals(JBFont.h2(), labels[1].font)
assertEquals(AllIcons.Actions.Download.iconWidth * 3, labels[2].icon.iconWidth)
assertEquals(AllIcons.Actions.Download.iconHeight * 3, labels[2].icon.iconHeight)
}
fun `test drop overlay is registered in overlay layer not blocker`() {
val root = SessionRootPanel()
val drop = SessionDropOverlay()
root.addOverlay(drop) { pane, _ ->
Rectangle(0, 0, pane.width, pane.height)
}
assertSame(root.overlay, drop.parent)
assertFalse(root.blocker.components.contains(drop))
}
fun `test setBlocked makes blocker visible and setBlocked false hides it`() {
val root = SessionRootPanel().apply { setSize(200, 100) }
root.doLayout()
@@ -135,4 +195,18 @@ class SessionRootPanelTest : BasePlatformTestCase() {
super.doLayout()
}
}
private fun dropCard(drop: SessionDropOverlay) = drop.components
.single()
.let { it as javax.swing.JComponent }
.components
.single()
.let { it as javax.swing.JComponent }
private fun dropLabels(drop: SessionDropOverlay): List<JBLabel> {
val stack = dropCard(drop).components.single() as javax.swing.JComponent
return stack.components
.map { it as javax.swing.JComponent }
.map { it.components.single() as JBLabel }
}
}
@@ -2,7 +2,13 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.attachment.AttachmentCard
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.AttachmentView
import ai.kilocode.client.session.views.PromptAttachmentView
import ai.kilocode.client.session.views.tool.ReadToolView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
@@ -10,6 +16,11 @@ import ai.kilocode.rpc.dto.PartDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.JBUI
import java.awt.Container
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.ScrollPaneConstants
/**
* Integration test: mutate [SessionModel] directly on the EDT and verify
@@ -154,6 +165,223 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertNull(gv.border)
}
fun `test assistant file part renders as attachment view`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent(
"a1",
PartDto(
id = "f1",
sessionID = "ses",
messageID = "a1",
type = "file",
mime = "image/png",
url = "file:///tmp/a.png",
filename = "a.png",
),
)
val view = panel.findMessage("a1")!!.part("f1")
assertTrue(view is AttachmentView)
assertEquals("AttachmentView#f1:a.png", view!!.dumpLabel())
assertNotNull(find(view, AttachmentCard::class.java))
assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") })
}
fun `test user file part renders as prompt attachment strip`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent(
"u1",
PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "image/png",
url = "file:///tmp/a.png",
filename = "a.png",
),
)
val view = panel.findMessage("u1")!!.part("f1")
assertTrue(view is PromptAttachmentView)
assertEquals("PromptAttachmentView#attachments:u1[f1]", view!!.dumpLabel())
assertNotNull(find(view, AttachmentCard::class.java))
assertFalse(buttons(view).any { it.accessibleContext.accessibleName == KiloBundle.message("prompt.attachment.remove", "a.png") })
}
fun `test user text and attachments share one prompt container`() {
val opened = mutableListOf<String>()
val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { _, it -> opened.add(it.url) })
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "look at this"))
model.updateContent(
"u1",
PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "image/png",
url = "data:image/png;base64,aGVsbG8=",
filename = "a.png",
),
)
model.updateContent(
"u1",
PartDto(
id = "f2",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "data:text/plain;base64,aGVsbG8=",
filename = "note.txt",
),
)
val msg = item.findMessage("u1")!!
val attachment = msg.part("f1")!!
val other = msg.part("f2")!!
assertSame(msg, attachment.parent)
assertSame(attachment, other)
assertEquals(listOf("p1", "f1", "f2"), msg.partIds())
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
assertEquals(2, findAll(attachment, AttachmentCard::class.java).size)
val cards = findAll(attachment, AttachmentCard::class.java)
for (card in cards) {
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
}
assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened)
}
fun `test empty sanitized user text does not create prompt panel`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "read these screenshots"))
model.updateContent("u1", part("p2", "u1", "text", text = " "))
model.updateContent(
"u1",
PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "image/png",
url = "data:image/png;base64,aGVsbG8=",
filename = "a.png",
),
)
val msg = panel.findMessage("u1")!!
assertNull(msg.part("p2"))
assertEquals(listOf("p1", "f1"), msg.partIds())
assertEquals(1, msg.components.filterIsInstance<TextView>().size)
assertEquals(1, msg.components.filterIsInstance<PromptAttachmentView>().size)
}
fun `test prompt text panel is removed when content becomes empty`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "visible"))
assertNotNull(panel.findMessage("u1")!!.part("p1"))
model.updateContent("u1", part("p1", "u1", "text", text = ""))
val msg = panel.findMessage("u1")!!
assertNull(msg.part("p1"))
assertTrue(msg.partIds().isEmpty())
assertEquals(0, msg.components.filterIsInstance<TextView>().size)
}
fun `test user attachment strip scrolls horizontally only`() {
model.upsertMessage(msg("u1", "user"))
for (i in 1..8) {
model.updateContent(
"u1",
PartDto(
id = "f$i",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "image/png",
url = "file:///tmp/$i.png",
filename = "$i.png",
),
)
}
val view = panel.findMessage("u1")!!.part("f1") as PromptAttachmentView
val height = view.preferredSize.height
val pane = view.scrollPane()
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy)
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy)
assertEquals(0, view.insets.top)
assertEquals(JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), view.insets.bottom)
assertEquals(
JBUI.scale(SessionUiStyle.View.Attachment.CARD_HEIGHT) +
pane.horizontalScrollBar.preferredSize.height +
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
height,
)
model.updateContent(
"u1",
PartDto(
id = "f9",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "image/png",
url = "file:///tmp/9.png",
filename = "9.png",
),
)
assertEquals(height, view.preferredSize.height)
assertEquals((1..9).map { "f$it" }, view.ids())
}
fun `test user read tool payload is hidden but assistant read tool renders`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("u1", toolPart("ut1", "u1", "read", "completed"))
model.updateContent("a1", toolPart("at1", "a1", "read", "completed"))
val user = panel.findMessage("u1")!!
val assistant = panel.findMessage("a1")!!
assertTrue(user.partIds().isEmpty())
assertNull(user.part("ut1"))
assertTrue(assistant.part("at1") is ReadToolView)
}
fun `test transcript attachment click delegates to attachment opener`() {
val opened = mutableListOf<Pair<String, String>>()
val item = SessionMessageListPanel(model, parent, openFile = {}, openAttachment = { msg, it -> opened.add(msg to it.url) })
model.upsertMessage(msg("u1", "user"))
model.updateContent(
"u1",
PartDto(
id = "f1",
sessionID = "ses",
messageID = "u1",
type = "file",
mime = "text/plain",
url = "data:text/plain;base64,aGVsbG8=",
filename = "note.txt",
),
)
val card = find(item.findMessage("u1")!!.part("f1")!!, AttachmentCard::class.java)!!
card.dispatchEvent(MouseEvent(card, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false))
assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened)
}
// ------ silent part types ------
fun `test step markers are not rendered in panel`() {
@@ -225,4 +453,35 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
private fun toolPart(id: String, mid: String, tool: String, state: String) = PartDto(
id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, state = state,
)
private fun <T : Any> find(root: java.awt.Component, type: Class<T>): T? {
if (type.isInstance(root)) return type.cast(root)
if (root is Container) {
for (child in root.components) {
val found = find(child, type)
if (found != null) return found
}
}
return null
}
private fun <T : Any> findAll(root: java.awt.Component, type: Class<T>): List<T> {
val out = mutableListOf<T>()
fun visit(node: java.awt.Component) {
if (type.isInstance(node)) out.add(type.cast(node))
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
private fun buttons(root: java.awt.Component): List<JButton> {
val out = mutableListOf<JButton>()
fun visit(node: java.awt.Component) {
if (node is JButton) out.add(node)
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
}
@@ -0,0 +1,236 @@
package ai.kilocode.client.session.ui.attachment
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.vfs.KiloPath
import ai.kilocode.client.vfs.KiloVirtualFile
import ai.kilocode.client.vfs.KiloVirtualFileSystem
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.PartDto
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.testFramework.replaceService
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
class AttachmentEditorKindTest : BasePlatformTestCase() {
fun testAttachmentParamsUseStableIdentityFields() {
val item = FileAttachment("part1").apply {
mime = "text/plain"
url = "data:text/plain;base64,aGVsbG8="
filename = "note.txt"
}
val params = attachmentParams("ses1", "msg1", item, "note.txt", "/repo")
val path = KiloPath(AttachmentEditorKind.ID, params).canonical()
val json = KiloVirtualFileSystem.getInstance().getPath(path)
val decoded = KiloVirtualFileSystem.decode(json)
assertEquals(path, decoded)
assertEquals(AttachmentEditorKind.ID, path.kind)
assertEquals("ses1", params["sessionId"])
assertEquals("msg1", params["messageId"])
assertEquals("part1", params["partId"])
assertFalse(params["attachmentKey"].isNullOrBlank())
assertEquals("note.txt", params["filename"])
assertEquals("text/plain", params["mime"])
assertEquals("/repo", params["directory"])
assertFalse(json.contains("projectHash", ignoreCase = true))
assertFalse(json.contains("launch", ignoreCase = true))
assertFalse(json.contains("time", ignoreCase = true))
assertFalse(json.contains("random", ignoreCase = true))
}
fun testSameParamsMapToSameVirtualPath() {
val params = linkedMapOf(
"sessionId" to "ses1",
"messageId" to "msg1",
"partId" to "part1",
"attachmentKey" to "key1",
"filename" to "note.txt",
"mime" to "text/plain",
"directory" to "/repo",
)
val one = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params))
val two = KiloVirtualFileSystem.getInstance().getPath(KiloPath(AttachmentEditorKind.ID, params.toList().reversed().toMap()))
assertEquals(one, two)
assertFalse(one.contains("/system/kilo/editors"))
assertFalse(one.contains("kiloattachment"))
}
fun testDuplicatePartAttachmentsMapToDistinctVirtualFiles() {
val first = FileAttachment("part1").apply {
mime = "text/plain"
url = "data:text/plain;base64,b25l"
filename = "note.txt"
}
val second = FileAttachment("part1").apply {
mime = "text/plain"
url = "data:text/plain;base64,dHdv"
filename = "note.txt"
}
val one = attachmentParams("ses1", "msg1", first, "note.txt", "/repo")
val two = attachmentParams("ses1", "msg1", second, "note.txt", "/repo")
assertFalse(one == two)
assertFalse(one["attachmentKey"] == two["attachmentKey"])
assertFalse(KiloPath(AttachmentEditorKind.ID, one) == KiloPath(AttachmentEditorKind.ID, two))
}
fun testVirtualFilesAreExcludedFromEditorHistory() {
ensureAttachmentEditorKind()
val file = KiloVirtualFile(KiloPath(AttachmentEditorKind.ID, mapOf(
"directory" to "/repo",
"sessionId" to "ses1",
"messageId" to "msg1",
"partId" to "part1",
"filename" to "note.txt",
)))
assertNull(VirtualFileManager.getInstance().findFileByUrl(file.url))
}
@Suppress("UnstableApiUsage")
fun testFetchUsesAttachmentKeyBeforeDuplicatePartId() {
val cs = CoroutineScope(SupervisorJob())
val app = FakeAppRpcApi()
val rpc = FakeSessionRpcApi()
app.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
val first = PartDto(
id = "part1",
sessionID = "ses1",
messageID = "msg1",
type = "file",
mime = "text/plain",
url = "data:text/plain;base64,b25l",
filename = "note.txt",
)
val second = first.copy(url = "data:text/plain;base64,dHdv")
rpc.history.add(MessageWithPartsDto(
info = MessageDto(
id = "msg1",
sessionID = "ses1",
role = "user",
time = MessageTimeDto(created = 0.0),
),
parts = listOf(first, second),
))
ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable)
project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable)
val item = FileAttachment("part1").apply {
mime = "text/plain"
url = second.url.orEmpty()
filename = "note.txt"
}
val results = mutableListOf<AttachmentData>()
val parent = Disposer.newDisposable()
try {
KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) {
results.add(it)
}
waitFor { results.any { it is AttachmentData.Text } }
assertTrue(results.any { it is AttachmentData.Connecting })
val data = results.last { it is AttachmentData.Text } as AttachmentData.Text
assertEquals("two", data.text)
assertEquals(1, rpc.attachmentParts.size)
assertEquals("msg1", rpc.attachmentParts.single().messageId)
assertEquals(0, rpc.historyCalls)
} finally {
Disposer.dispose(parent)
cs.cancel()
}
}
@Suppress("UnstableApiUsage")
fun testLoadShowsConnectionFailedUntilRetryBecomesReady() = runBlocking {
val cs = CoroutineScope(SupervisorJob())
val app = FakeAppRpcApi()
val rpc = FakeSessionRpcApi()
val part = PartDto(
id = "part1",
sessionID = "ses1",
messageID = "msg1",
type = "file",
mime = "text/plain",
url = "data:text/plain;base64,b2s=",
filename = "note.txt",
)
rpc.history.add(MessageWithPartsDto(
info = MessageDto(
id = "msg1",
sessionID = "ses1",
role = "user",
time = MessageTimeDto(created = 0.0),
),
parts = listOf(part),
))
app.state.value = KiloAppStateDto(KiloAppStatusDto.ERROR)
ApplicationManager.getApplication().replaceService(KiloAppService::class.java, KiloAppService(cs, app), testRootDisposable)
project.replaceService(KiloSessionService::class.java, KiloSessionService(project, cs, rpc), testRootDisposable)
val item = FileAttachment("part1").apply {
mime = part.mime.orEmpty()
url = part.url.orEmpty()
filename = part.filename.orEmpty()
}
val results = mutableListOf<AttachmentData>()
val parent = Disposer.newDisposable()
try {
KiloAttachmentEditorService(project, cs).load(ref("ses1", "msg1", item, "note.txt", "/repo"), parent) {
results.add(it)
}
waitFor { results.any { it is AttachmentData.ConnectionFailed } }
assertTrue(results.any { it is AttachmentData.Connecting })
assertTrue(results.any { it is AttachmentData.ConnectionFailed })
app.state.value = KiloAppStateDto(KiloAppStatusDto.READY)
waitFor { results.any { it is AttachmentData.Text } }
val data = results.last { it is AttachmentData.Text } as AttachmentData.Text
assertEquals("ok", data.text)
} finally {
Disposer.dispose(parent)
cs.cancel()
}
}
private fun waitFor(done: () -> Boolean) {
val until = System.currentTimeMillis() + 5_000
while (!done() && System.currentTimeMillis() < until) {
UIUtil.dispatchAllInvocationEvents()
Thread.sleep(50)
}
assertTrue(done())
}
private fun ref(session: String, message: String, item: FileAttachment, name: String, dir: String): AttachmentRef {
val params = attachmentParams(session, message, item, name, dir)
return AttachmentRef(
directory = params.getValue("directory"),
sessionId = params.getValue("sessionId"),
messageId = params.getValue("messageId"),
partId = params.getValue("partId"),
attachmentKey = params["attachmentKey"],
filename = params.getValue("filename"),
mime = params.getValue("mime"),
)
}
}
@@ -0,0 +1,27 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.cli.KiloCliParser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class KiloCliParserTest {
@Test
fun `tag extracts trimmed tool xml value`() {
val text = """
<path>
/tmp/example.txt
</path>
<type>file</type>
""".trimIndent()
assertEquals("/tmp/example.txt", KiloCliParser.tag(text, "path"))
assertEquals("file", KiloCliParser.tag(text, "type"))
}
@Test
fun `tag returns null for blank or missing value`() {
assertNull(KiloCliParser.tag("<path> </path>", "path"))
assertNull(KiloCliParser.tag("<type>file</type>", "path"))
}
}
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -46,6 +47,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
/** Message history returned by [messages]. */
val history = mutableListOf<MessageWithPartsDto>()
var historyGate: CompletableDeferred<Unit>? = null
var historyCalls = 0
private set
/** Recent sessions returned by [recent]. */
val recent = mutableListOf<SessionDto>()
@@ -82,6 +85,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
var enhanceGate: CompletableDeferred<Unit>? = null
var enhanceThrows: Exception? = null
val prompts = mutableListOf<Triple<String, String, PromptDto>>()
val attachmentParts = mutableListOf<AttachmentCall>()
val aborts = mutableListOf<Pair<String, String>>()
val compacts = mutableListOf<Triple<String, String, ModelSelectionDto>>()
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
@@ -101,6 +105,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
private set
data class CloudCall(val directory: String, val cursor: String?, val limit: Int, val gitUrl: String?)
data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?)
// --- Implementation ---
@@ -202,10 +207,25 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> {
assertNotEdt("messages")
historyCalls++
historyGate?.await()
return history.toList()
}
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? {
assertNotEdt("attachmentPart")
attachmentParts.add(AttachmentCall(id, directory, messageId, partId, attachmentKey))
historyGate?.await()
return history
.firstOrNull { it.info.id == messageId }
?.parts
?.firstOrNull {
if (it.type != "file") return@firstOrNull false
if (!attachmentKey.isNullOrBlank()) key(it.id, it.filename.orEmpty(), it.url.orEmpty()) == attachmentKey
else it.id == partId
}
}
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> {
assertNotEdt("events")
return eventFlow?.invoke(id, directory) ?: events
@@ -245,4 +265,10 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
assertNotEdt("pendingQuestions")
return pendingQuestionList.toList()
}
private fun key(part: String, name: String, url: String): String {
val value = listOf(part, name, url).joinToString("\u0000")
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
return bytes.take(16).joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,18 @@
package ai.kilocode.cli
import java.util.concurrent.ConcurrentHashMap
object KiloCliParser {
private val tags = ConcurrentHashMap<String, Regex>()
fun tag(text: String, name: String): String? =
tags.computeIfAbsent(name) {
val tag = Regex.escape(it)
Regex("<$tag>\\s*([\\s\\S]*?)\\s*</$tag>")
}
.find(text)
?.groupValues
?.getOrNull(1)
?.trim()
?.takeIf { it.isNotBlank() }
}
@@ -50,14 +50,23 @@ object ChatLogSummary {
fun prompt(prompt: PromptDto): String {
val out = mutableListOf<String>()
val text = prompt.parts.joinToString("\n") { it.text }
val text = prompt.parts.mapNotNull { it.text }.joinToString("\n")
val files = prompt.parts.filter { it.type == "file" }
out += "kind=prompt"
out += "parts=${prompt.parts.size}"
out += "chars=${text.length}"
if (files.isNotEmpty()) out += "attachments=${files.size}"
files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }
.takeIf { it > 0 }
?.let { out += "media=$it" }
prompt.parts.map { it.type }
.distinct()
.takeIf { it.isNotEmpty() }
?.let { out += "types=${it.joinToString(",")}" }
files.mapNotNull { it.mime ?: it.type }
.distinct()
.takeIf { it.isNotEmpty() }
?.let { out += "attachmentTypes=${it.joinToString(",")}" }
prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" }
model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" }
prompt.variant?.takeIf { it.isNotBlank() }?.let { out += "variant=$it" }
@@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
import ai.kilocode.rpc.dto.PermissionReplyDto
import ai.kilocode.rpc.dto.PermissionRequestDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
@@ -86,6 +87,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** Load message history for a session. */
suspend fun messages(id: String, directory: String): List<MessageWithPartsDto>
/** Load one attachment part from a session without returning full history to the frontend. */
suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto?
/** Subscribe to streaming chat events for a specific session. */
suspend fun events(id: String, directory: String): Flow<ChatEventDto>
@@ -72,6 +72,9 @@ data class PartDto(
val reason: String? = null,
val cost: Double? = null,
val tokens: TokensDto? = null,
val mime: String? = null,
val url: String? = null,
val filename: String? = null,
)
@Serializable
@@ -96,7 +99,10 @@ data class PromptDto(
@Serializable
data class PromptPartDto(
val type: String,
val text: String,
val text: String? = null,
val mime: String? = null,
val url: String? = null,
val filename: String? = null,
)
// --- Streaming Events ---