Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
df39c2c
feat(jetbrains): add editor tab VFS infrastructure
kirillk Jun 8, 2026
a8b127e
feat(jetbrains): support prompt file attachments
kirillk Jun 8, 2026
7481b6f
feat(jetbrains): refine attachment preview cards
kirillk Jun 8, 2026
793cf93
feat(jetbrains): add session file drop overlay
kirillk Jun 9, 2026
b85ce6d
fix(jetbrains): refine drop overlay icon spacing
kirillk Jun 9, 2026
49339a2
feat(jetbrains): support pasting prompt attachments
kirillk Jun 9, 2026
52dfa54
fix(jetbrains): sanitize read payloads in parser
kirillk Jun 9, 2026
9f8f698
fix(jetbrains): refine prompt attachment rendering
kirillk Jun 9, 2026
22310e1
Merge branch 'glaze-fireplace' into balanced-backpack
kirillk Jun 9, 2026
2f9c6ec
feat(jetbrains): open embedded attachments in editor tabs
kirillk Jun 10, 2026
71484fb
Merge remote-tracking branch 'origin/main' into balanced-backpack
kirillk Jun 10, 2026
3dc1ac5
fix(jetbrains): stabilize Kilo VFS keys
kirillk Jun 10, 2026
517777d
Merge remote-tracking branch 'origin/main' into balanced-backpack
kirillk Jun 11, 2026
709a53c
fix(jetbrains): keep kilo vfs editor state backend-owned
kirillk Jun 11, 2026
98a14c3
fix(jetbrains): open attachments as physical kilo files
kirillk Jun 12, 2026
551b9e2
Merge remote-tracking branch 'origin/main' into balanced-backpack
kirillk Jun 13, 2026
683c32b
Merge remote-tracking branch 'origin/balanced-backpack' into balanced…
kirillk Jun 13, 2026
38a4083
fix(jetbrains): open attachments through frontend vfs
kirillk Jun 13, 2026
19842bc
Merge remote-tracking branch 'origin/main' into balanced-backpack
kirillk Jun 13, 2026
e951778
Merge remote-tracking branch 'origin/balanced-backpack' into balanced…
kirillk Jun 13, 2026
7df78e8
fix(jetbrains): resolve prompt panel merge fallout
kirillk Jun 13, 2026
9704197
fix(jetbrains): address attachment review fixes
kirillk Jun 14, 2026
389ad42
fix(jetbrains): address attachment review feedback
kirillk Jun 14, 2026
9d00369
refactor(jetbrains): share CLI tool tag parsing
kirillk Jun 15, 2026
1f44036
fix(jetbrains): address prompt attachment review
kirillk Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-hide-read-payloads.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-horizontal-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show JetBrains prompt attachments in one horizontal scrolling row in session history.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-paste-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Support pasting files and images into JetBrains chat prompts as attachments.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-prompt-attachment-preview.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-prompt-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Support sending file and image attachments from the JetBrains chat prompt.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-session-drop-overlay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Support dropping files anywhere in a JetBrains chat session to add them to the prompt.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-vfs-frontend-handoff.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/render-jetbrains-attachment-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Render file attachments as attachment cards in JetBrains prompts and session history.
5 changes: 5 additions & 0 deletions .changeset/silent-comet-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Open embedded transcript attachments in stable Kilo editor tabs.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}")
}
Expand All @@ -111,6 +115,7 @@ class KiloBackendChatManager(
watcher = null
client = null
base = null
normalizer = KiloCliDataParser.ChatEventNormalizer()
log.info("Chat manager stopped")
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>()

// ================================================================
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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]""")
Expand Down Expand Up @@ -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`.
*/
Expand Down Expand Up @@ -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"),
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 ------
Expand Down
Loading
Loading