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