mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 14:07:20 +08:00
fix(jetbrains): render prompt attachments as chips
This commit is contained in:
@@ -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.
|
||||
+23
-3
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+6
-5
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+12
-3
@@ -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)}"
|
||||
|
||||
+2
@@ -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. */
|
||||
|
||||
+20
@@ -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
|
||||
|
||||
+72
@@ -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,
|
||||
|
||||
+3
@@ -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. */
|
||||
|
||||
+28
-7
@@ -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() }
|
||||
|
||||
+44
-20
@@ -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(
|
||||
|
||||
+22
-8
@@ -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<String, FileAttachment>()
|
||||
private val cards = LinkedHashMap<String, AttachmentCard>()
|
||||
private val cards = LinkedHashMap<String, JComponent>()
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-1
@@ -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<Unit>()
|
||||
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
|
||||
}
|
||||
|
||||
+6
-3
@@ -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<PromptAttachmentView>().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<Component> {
|
||||
|
||||
+33
-7
@@ -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<PromptAttachmentView>().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<PromptAttachmentView>().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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ interface KiloWorkspaceRpcApi : RemoteApi<Unit> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user