diff --git a/.changeset/jetbrains-prompt-attachments.md b/.changeset/jetbrains-prompt-attachments.md new file mode 100644 index 00000000000..ac1f1f117e2 --- /dev/null +++ b/.changeset/jetbrains-prompt-attachments.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render prompt attachments inside the sent message bubble with file chips, image previews, and selection-aware file opening. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 8140b86bffd..14f9cbe12da 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -26,6 +26,8 @@ import com.intellij.execution.process.CapturingProcessHandler import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service +import com.intellij.openapi.editor.ScrollType +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager @@ -288,7 +290,7 @@ class KiloWorkspaceRpcApiImpl internal constructor( }.ifBlank { null } } - override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { + override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean { val item = clean(path) ?: return false val target = file(item)?.takeIf { it.isAbsolute } ?: return false val vf = LocalFileSystem.getInstance().refreshAndFindFileByPath(target.toString()) ?: return false @@ -296,7 +298,7 @@ class KiloWorkspaceRpcApiImpl internal constructor( LOG.warn("No project available to open file: $path") return false } - navigate(project, vf, line, column) + navigate(project, vf, line, column, endLine) return true } @@ -374,8 +376,26 @@ class KiloWorkspaceRpcApiImpl internal constructor( null } - private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null) = suspendCancellableCoroutine { cont -> + private suspend fun navigate(project: Project, file: VirtualFile, line: Int? = null, column: Int? = null, endLine: Int? = null) = suspendCancellableCoroutine { cont -> ApplicationManager.getApplication().invokeLater({ + if (line != null && endLine != null) { + val editor = FileEditorManager.getInstance(project).openTextEditor( + OpenFileDescriptor(project, file, (line - 1).coerceAtLeast(0), 0), + true, + ) + val doc = editor?.document + if (editor != null && doc != null && doc.lineCount > 0) { + val start = (line - 1).coerceIn(0, doc.lineCount - 1) + val end = (endLine - 1).coerceIn(start, doc.lineCount - 1) + val from = doc.getLineStartOffset(start) + val to = doc.getLineEndOffset(end) + editor.selectionModel.setSelection(from, to) + editor.caretModel.moveToOffset(from) + editor.scrollingModel.scrollToCaret(ScrollType.CENTER) + } + if (cont.isActive) cont.resume(Unit) + return@invokeLater + } val descriptor = if (line == null) { OpenFileDescriptor(project, file) } else { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index 66c3625bd6e..33d628d2a2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -179,19 +179,19 @@ class KiloWorkspaceService internal constructor( } } - suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null): Boolean { + suspend fun openPath(directory: String, path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean { val match = files(directory, path).firstOrNull() ?: return false return try { - call { openFile(match.path, line, column) } + call { openFile(match.path, line, column, endLine) } } catch (e: Exception) { LOG.warn("workspace file open failed for path=${match.path}", e) false } } - suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean { + suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean { return try { - call { openFile(path, line, column) } + call { openFile(path, line, column, endLine) } } catch (e: Exception) { LOG.warn("workspace file open failed for path=$path", e) false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt index daee52fea8f..88f3804098c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionFileLinks.kt @@ -59,7 +59,7 @@ class SessionFileLinks( } val target = parse(href) scope.launch { - val ok = service.openPath(dir, target.path, target.line, target.column) + val ok = service.openPath(dir, target.path, target.line, target.column, target.endLine) if (ok) { track(target, "direct") return@launch @@ -72,7 +72,7 @@ class SessionFileLinks( when (val result = decide(false, found)) { Resolution.Opened -> Unit is Resolution.OpenDirect -> { - val opened = service.openPath(dir, result.file.path, target.line, target.column) + val opened = service.openPath(dir, result.file.path, target.line, target.column, target.endLine) track(target, if (opened) "search_direct" else "missing") } is Resolution.Choose -> { @@ -104,7 +104,7 @@ class SessionFileLinks( .createPopupChooserBuilder(files) .setRenderer(FileRenderer()) .setItemChosenCallback { file -> - scope.launch { service.openPath(dir, file.path, target.line, target.column) } + scope.launch { service.openPath(dir, file.path, target.line, target.column, target.endLine) } } .createPopup() popup.show(anchor ?: RelativePoint.getCenterOf(root)) @@ -143,11 +143,11 @@ class SessionFileLinks( data object Missing : Resolution } - data class Target(val path: String, val line: Int? = null, val column: Int? = null) + data class Target(val path: String, val line: Int? = null, val column: Int? = null, val endLine: Int? = null) companion object { private const val FILE_SEARCH_LIMIT = 50 - private val LINE = Regex(":(\\d+)(?:-\\d+)?(?::(\\d+))?$") + private val LINE = Regex(":(\\d+)(?:-(\\d+))?(?::(\\d+))?$") private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):") fun parse(href: String): Target { @@ -155,6 +155,7 @@ class SessionFileLinks( return Target( href.substring(0, match.range.first), match.groupValues[1].toIntOrNull(), + match.groupValues.getOrNull(3)?.takeIf { it.isNotBlank() }?.toIntOrNull(), match.groupValues.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull(), ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 7635c282e9c..4c7df90e8a8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -921,12 +921,13 @@ class SessionUi( return } if (uri.scheme == "file") { - val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run { + val path = runCatching { Path.of(cleanAttachmentUri(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") - fileLinks.open(path, null) + val target = attachmentHref(path, item) + LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$target") + fileLinks.open(target, null) return } LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}") @@ -937,6 +938,14 @@ class SessionUi( ?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() } ?: "attachment" + private fun attachmentHref(path: String, item: FileAttachment): String { + val start = item.startLine ?: return path + val end = item.endLine ?: start + return "$path:$start-$end" + } + + private fun cleanAttachmentUri(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null) + private fun attachmentUrl(url: String): String { val scheme = url.substringBefore(':', missingDelimiterValue = "none") return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index beee188bd70..225b88c6bbe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -73,6 +73,8 @@ class FileAttachment(id: String) : Content(id) { var url: String = "" var filename: String? = null var source: PartSourceDto? = null + var startLine: Int? = null + var endLine: Int? = null } /** Tool invocation with lifecycle state. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 1d918c70bcd..53878cd75f1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -498,6 +498,9 @@ class SessionModel { existing.url = dto.url ?: "" existing.filename = dto.filename existing.source = dto.source + val range = range(existing.url) + existing.startLine = range?.first + existing.endLine = range?.last } is Tool -> { val old = existing.childSessionId @@ -552,6 +555,9 @@ class SessionModel { url = dto.url ?: "" filename = dto.filename source = dto.source + val range = range(url) + startLine = range?.first + endLine = range?.last } "tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply { messageID = dto.messageID @@ -581,6 +587,20 @@ class SessionModel { for (l in listeners) l.onEvent(event) } + private fun range(url: String): IntRange? { + val query = runCatching { java.net.URI.create(url).rawQuery }.getOrNull() ?: return null + val args = query.split('&') + .mapNotNull { + val index = it.indexOf('=') + if (index < 0) return@mapNotNull null + it.substring(0, index) to it.substring(index + 1) + } + .toMap() + val start = args["start"]?.toIntOrNull()?.takeIf { it > 0 } ?: return null + val end = args["end"]?.toIntOrNull()?.takeIf { it >= start } ?: start + return start..end + } + private fun trackChild(messageId: String, content: Content) { val tool = content as? Tool ?: return val child = tool.childSessionId ?: return diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt index 2ae5cdc7596..f433862985a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/attachment/AttachmentCard.kt @@ -47,6 +47,78 @@ data class AttachmentCardItem( val path: Path? = null, ) +class AttachmentChip( + private val item: AttachmentCardItem, + private val file: Boolean, + private val startLine: Int? = null, + private val endLine: Int? = null, + open: (() -> Unit)? = null, +) : JPanel(BorderLayout()) { + private val tip = tooltip(item) + private val open = open?.let { callback -> + object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + callback() + } + } + } + + init { + isOpaque = false + border = JBUI.Borders.empty(0, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HORIZONTAL_PADDING)) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = tip + accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name) + val label = JBLabel(label()).apply { + icon = attachmentIcon(item.mime, item.name) + iconTextGap = JBUI.scale(SessionUiStyle.View.Attachment.CHIP_ICON_GAP) + toolTipText = tip + } + add(label, BorderLayout.CENTER) + watch(this) + } + + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + return Dimension(size.width, JBUI.scale(SessionUiStyle.View.Attachment.CHIP_HEIGHT)) + } + + override fun getMinimumSize(): Dimension = preferredSize + + 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) + } + + private fun label(): String { + val start = startLine + val end = endLine + if (file && start != null && end != null) return KiloBundle.message("session.attachment.file.range", item.name, start, end) + if (file) return item.name + return KiloBundle.message("session.attachment.unknown", item.mime.ifBlank { "unknown" }) + } + + private fun watch(node: Component) { + if (node is JComponent) node.toolTipText = tip + open?.let { + node.removeMouseListener(it) + node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + node.addMouseListener(it) + } + if (node is Container) node.components.forEach(::watch) + } +} + open class AttachmentCard( private val item: AttachmentCardItem, remove: (() -> Unit)? = null, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 82cdcd40321..aa8ce97a96f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -121,6 +121,9 @@ object SessionUiStyle { const val CARD_HEIGHT = 59 const val CLOSE_SIZE = 18 const val CORNER_ARC = 8 + const val CHIP_HEIGHT = 28 + const val CHIP_HORIZONTAL_PADDING = 8 + const val CHIP_ICON_GAP = 6 } /** Full-session file drop overlay geometry and colors. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt index 95de4237000..7f21a7acf25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/AttachmentView.kt @@ -6,12 +6,14 @@ 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.attachment.AttachmentChip 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 +import javax.swing.JComponent class AttachmentView( private var item: FileAttachment, @@ -48,20 +50,31 @@ class AttachmentView( 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 chip(item: FileAttachment): JComponent { + val card = AttachmentCardItem(name(item), item.mime, item.url) + if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) }) + return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) }) + } - private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename + private fun same(next: FileAttachment) = item.mime == next.mime && + item.url == next.url && + item.filename == next.filename && + item.startLine == next.startLine && + item.endLine == next.endLine + + private fun file(item: FileAttachment): Boolean { + if (item.source?.path?.isNotBlank() == true) return true + val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false + return uri.scheme == "file" + } companion object { fun openDefault(item: FileAttachment, openFile: SessionFileOpener, 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, null) + val path = runCatching { Path.of(clean(uri)).toString() }.getOrNull() ?: return + openFile(href(path, item), null) return } if (SessionFileLinks.isFileHref(url)) { @@ -70,6 +83,14 @@ class AttachmentView( } openUrl(url) } + + private fun href(path: String, item: FileAttachment): String { + val start = item.startLine ?: return path + val end = item.endLine ?: start + return "$path:$start-$end" + } + + private fun clean(uri: URI): URI = URI(uri.scheme, uri.authority, uri.path, null, null) } private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index c4ae7dc75b8..3aa839f8059 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -212,12 +212,11 @@ class MessageView( } } val view = view(content) - val item = wrapPrompt(view) view.resize = resize view.hover = hover view.applyStyle(style) parts[content.id] = view - add(item) + wrapPrompt(view)?.let { add(it) } } @RequiresEdt @@ -227,7 +226,9 @@ class MessageView( it.hover = hover it.applyStyle(style) attachments = it - add(it) + val node = ensurePromptWrap() + promptBox?.add(it, BorderLayout.SOUTH) + if (node.parent == null) add(node) } view.upsert(content) parts[content.id] = view @@ -253,20 +254,19 @@ class MessageView( @RequiresEdt private fun replacePart(content: Content, existing: PartView) { - val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount + val at = components.indexOfFirst { it === existing || it === wrap }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) aliases.values.removeAll { it == content.id } sources.keys.removeAll { it !in aliases } - detach(existing) - remove(existing) + removeView(existing) + if (existing === prompt) prompt = null Disposer.dispose(existing) val view = view(content) - val item = wrapPrompt(view) view.resize = resize view.hover = hover view.applyStyle(style) parts[content.id] = view - add(item, at) + wrapPrompt(view)?.let { add(it, at) } syncBorder() refresh() } @@ -294,10 +294,11 @@ class MessageView( } aliases.values.removeAll { it == contentId } sources.keys.removeAll { it !in aliases } - detach(view) - remove(view) + removeView(view) Disposer.dispose(view) + if (view === prompt) prompt = null syncBorder() + syncPromptWrap() refresh() return true } @@ -325,10 +326,10 @@ class MessageView( @RequiresEdt private fun rebuildParts() { parts.values.distinct().forEach { - detach(it) - remove(it) + removeView(it) Disposer.dispose(it) } + wrap?.let { remove(it) } parts.clear() aliases.clear() sources.clear() @@ -442,10 +443,10 @@ class MessageView( @RequiresEdt override fun dispose() { parts.values.forEach { - detach(it) - remove(it) + removeView(it) Disposer.dispose(it) } + wrap?.let { remove(it) } parts.clear() aliases.clear() sources.clear() @@ -502,19 +503,42 @@ class MessageView( } @RequiresEdt - private fun wrapPrompt(view: PartView): JComponent { + private fun removeView(view: PartView) { + detach(view) + view.parent?.remove(view) + } + + @RequiresEdt + private fun wrapPrompt(view: PartView): JComponent? { if (role != SessionUiStyle.View.Message.USER_ROLE) return view if (view !is PromptView) return view prompt = view + val node = ensurePromptWrap() + val box = promptBox ?: return node + if (view.parent !== box) box.add(view, BorderLayout.CENTER) + node.bar.setActive(true) + return node.takeIf { it.parent == null } + } + + @RequiresEdt + private fun ensurePromptWrap(): PromptWrap { + val existing = wrap + if (existing != null) return existing val box = JPanel(BorderLayout()).also { it.isOpaque = false - it.add(view, BorderLayout.CENTER) promptBox = it } - val node = PromptWrap(box) - wrap = node - node.bar.setActive(true) - return node + return PromptWrap(box).also { wrap = it } + } + + @RequiresEdt + private fun syncPromptWrap() { + val node = wrap ?: return + val box = promptBox ?: return + if (box.componentCount > 0) return + node.parent?.remove(node) + wrap = null + promptBox = null } private inner class PromptWrap( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt index a01148a25fc..fb15e0e1d22 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptAttachmentView.kt @@ -4,6 +4,7 @@ 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.attachment.AttachmentChip import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.UiStyle @@ -12,6 +13,8 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.Dimension +import java.net.URI +import javax.swing.JComponent import javax.swing.ScrollPaneConstants class PromptAttachmentView( @@ -21,7 +24,7 @@ class PromptAttachmentView( override val contentId: String = "attachments:$messageId" private val items = LinkedHashMap() - private val cards = LinkedHashMap() + private val cards = LinkedHashMap() private val row = Stack.horizontal(gap = UiStyle.Gap.sm()) private val scroll = JBScrollPane(row).apply { border = null @@ -35,9 +38,9 @@ class PromptAttachmentView( isOpaque = false border = JBUI.Borders.empty( 0, - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + 0, JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING), + 0, ) add(scroll) } @@ -111,12 +114,23 @@ class PromptAttachmentView( repaint() } - private fun card(item: FileAttachment) = AttachmentCard( - AttachmentCardItem(name(item), item.mime, item.url), - open = { openAttachment(item) }, - ) + private fun card(item: FileAttachment): JComponent { + val card = AttachmentCardItem(name(item), item.mime, item.url) + if (item.mime.startsWith("image/")) return AttachmentCard(card, open = { openAttachment(item) }) + return AttachmentChip(card, file = file(item), startLine = item.startLine, endLine = item.endLine, open = { openAttachment(item) }) + } - private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename + private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && + a.url == b.url && + a.filename == b.filename && + a.startLine == b.startLine && + a.endLine == b.endLine + + private fun file(item: FileAttachment): Boolean { + if (item.source?.path?.isNotBlank() == true) return true + val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return false + return uri.scheme == "file" + } private fun bar() = scroll.horizontalScrollBar.preferredSize.height diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index f03000a254e..2c1c665a0ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -227,6 +227,8 @@ 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} +session.attachment.file.range={0}:{1}-{2} +session.attachment.unknown=Attached content ({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. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt index 13c56f27f06..1294933f4a8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionFileLinksTest.kt @@ -41,7 +41,7 @@ class SessionFileLinksTest : BasePlatformTestCase() { fun `test parse strips line range and column suffixes`() { assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12")) - assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12), SessionFileLinks.parse("src/Foo.kt:12-20")) + assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, endLine = 20), SessionFileLinks.parse("src/Foo.kt:12-20")) assertEquals(SessionFileLinks.Target("src/Foo.kt", line = 12, column = 3), SessionFileLinks.parse("src/Foo.kt:12:3")) } @@ -96,6 +96,18 @@ class SessionFileLinksTest : BasePlatformTestCase() { assertEquals("true", events.single().second["hasLine"]) } + fun `test open forwards line range to workspace service`() = runBlocking { + val file = WorkspaceFileDto("/test/src/Foo.kt", "Foo.kt") + val done = CompletableDeferred() + rpc.fileResolver = { path -> if (path == "src/Foo.kt") listOf(file) else emptyList() } + val links = SessionFileLinks("/test", service, scope, JPanel(), openUrl = {}) { _, _ -> done.complete(Unit) } + + links.open("src/Foo.kt:12-20", null) + withTimeout(OPEN_TIMEOUT_MS) { done.await() } + + assertEquals(listOf(FakeWorkspaceRpcApi.Opened("/test/src/Foo.kt", 12, null, 20)), rpc.openedFiles) + } + private companion object { const val OPEN_TIMEOUT_MS = 5_000L } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 95ff55bf948..8f369199893 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -22,6 +22,7 @@ import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.views.MessageToolbar import ai.kilocode.client.session.views.MessageView +import ai.kilocode.client.session.views.PromptAttachmentView import ai.kilocode.client.session.views.TextView import ai.kilocode.client.session.views.TurnView import ai.kilocode.client.session.views.base.PartView @@ -809,7 +810,9 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { layout(message) val box = promptBox(message) val point = SwingUtilities.convertPoint(box, Point(), message) - assertTrue("prompt box should be below attachment", point.y > 0) + val attachment = components(message).filterIsInstance().single() + val attachmentPoint = SwingUtilities.convertPoint(attachment, Point(), box) + assertTrue("attachment should be inside prompt box below prompt text", attachmentPoint.y > 0) val image = BufferedImage(message.width, message.height, BufferedImage.TYPE_INT_ARGB) val graphics = image.createGraphics() @@ -818,7 +821,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { val line = SessionUiStyle.View.Outline.color().rgb assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb) - assertFalse(line == Color(image.getRGB(point.x + box.width / 2, 0), true).rgb) + assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y + box.height - 1), true).rgb) } fun `test created ContentDelta is not double applied`() { @@ -1635,7 +1638,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { } private fun promptBox(root: MessageView): Component { - return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView } + return components(root).first { it.parent != root && it is JPanel && it.components.any { child -> child is TextView } } } private fun components(root: Component): List { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index dee8f91c9ff..188ef09d37f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -3,6 +3,7 @@ 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.attachment.AttachmentChip import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.AttachmentView import ai.kilocode.client.session.views.PromptAttachmentView @@ -20,6 +21,7 @@ import ai.kilocode.rpc.dto.PartSourceTextDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.Container import java.awt.event.MouseEvent @@ -270,16 +272,21 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val attachment = msg.part("f1")!! val other = msg.part("f2")!! - assertSame(msg, attachment.parent) + assertNotSame(msg, attachment.parent) assertSame(attachment, other) assertEquals(listOf("p1", "f1", "f2"), msg.partIds()) - assertEquals(1, msg.components.filterIsInstance().size) - assertEquals(2, findAll(attachment, AttachmentCard::class.java).size) + assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size) + assertEquals(1, findAll(attachment, AttachmentCard::class.java).size) + assertEquals(1, findAll(attachment, AttachmentChip::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)) } + val chips = findAll(attachment, AttachmentChip::class.java) + for (chip in chips) { + chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) + } assertEquals(listOf("data:image/png;base64,aGVsbG8=", "data:text/plain;base64,aGVsbG8="), opened) } @@ -356,7 +363,26 @@ class SessionUiUpdateTest : BasePlatformTestCase() { val view = panel.findMessage("u1")!!.part("f1") assertTrue(view is PromptAttachmentView) - assertNotNull(find(view!!, AttachmentCard::class.java)) + assertNotNull(find(view!!, AttachmentChip::class.java)) + } + + fun `test source less file selection renders filename range chip`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", PartDto( + id = "f1", + sessionID = "ses", + messageID = "u1", + type = "file", + mime = "text/plain", + url = "file:///tmp/HvJwtFilter.java?start=12&end=40", + filename = "HvJwtFilter.java", + )) + + val view = panel.findMessage("u1")!!.part("f1")!! + val chip = find(view, AttachmentChip::class.java) + + assertNotNull(chip) + assertTrue(findAll(chip!!, JBLabel::class.java).any { it.text == "HvJwtFilter.java:12-40" }) } fun `test source backed image attachment still renders in prompt strip`() { @@ -400,7 +426,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertNull(msg.part("p2")) assertEquals(listOf("p1", "f1"), msg.partIds()) assertTrue(msg.part("p1") is TextView) - assertEquals(1, msg.components.filterIsInstance().size) + assertEquals(1, findAll(msg, PromptAttachmentView::class.java).size) } fun `test prompt text panel is removed when content becomes empty`() { @@ -497,8 +523,8 @@ class SessionUiUpdateTest : BasePlatformTestCase() { ), ) - 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)) + val chip = find(item.findMessage("u1")!!.part("f1")!!, AttachmentChip::class.java)!! + chip.dispatchEvent(MouseEvent(chip, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, 1, 1, 1, false)) assertEquals(listOf("u1" to "data:text/plain;base64,aGVsbG8="), opened) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt index 327566bf9c3..da98aa58b5a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeWorkspaceRpcApi.kt @@ -111,10 +111,10 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return branchName } - override suspend fun openFile(path: String, line: Int?, column: Int?): Boolean { + override suspend fun openFile(path: String, line: Int?, column: Int?, endLine: Int?): Boolean { assertNotEdt("openFile") opened.add(path) - openedFiles.add(Opened(path, line, column)) + openedFiles.add(Opened(path, line, column, endLine)) return openResult } @@ -150,5 +150,5 @@ class FakeWorkspaceRpcApi : KiloWorkspaceRpcApi { return openResult } - data class Opened(val path: String, val line: Int?, val column: Int?) + data class Opened(val path: String, val line: Int?, val column: Int?, val endLine: Int? = null) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt index f609a796da1..bebd2c38690 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorkspaceRpcApi.kt @@ -67,7 +67,7 @@ interface KiloWorkspaceRpcApi : RemoteApi { suspend fun branchName(directory: String): String? /** Open an absolute backend file path in the IDE. */ - suspend fun openFile(path: String, line: Int? = null, column: Int? = null): Boolean + suspend fun openFile(path: String, line: Int? = null, column: Int? = null, endLine: Int? = null): Boolean /** Resolve the editable local config target. */ suspend fun localConfigTarget(directory: String): ConfigTargetDto