diff --git a/.changeset/jetbrains-diff-preview-fixes.md b/.changeset/jetbrains-diff-preview-fixes.md new file mode 100644 index 00000000000..1509469a9ab --- /dev/null +++ b/.changeset/jetbrains-diff-preview-fixes.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains diff previews by hiding hunk headers and adding full-path tooltips to clickable file links. diff --git a/.changeset/jetbrains-edit-diff-view.md b/.changeset/jetbrains-edit-diff-view.md new file mode 100644 index 00000000000..bfae54569e0 --- /dev/null +++ b/.changeset/jetbrains-edit-diff-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render edit tool results with a clickable file target and a highlighted, simplified diff view. diff --git a/.changeset/jetbrains-edit-file-links.md b/.changeset/jetbrains-edit-file-links.md new file mode 100644 index 00000000000..07a5340d089 --- /dev/null +++ b/.changeset/jetbrains-edit-file-links.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Open edit tool file links directly when multiple files share the same name. diff --git a/.changeset/jetbrains-multi-file-patch-view.md b/.changeset/jetbrains-multi-file-patch-view.md new file mode 100644 index 00000000000..c465a8a5296 --- /dev/null +++ b/.changeset/jetbrains-multi-file-patch-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render multi-file apply_patch edits as a "Patch" with a file-count tag and one section per file, each showing a clickable filename link and its own changes badge aligned with the diff. diff --git a/.changeset/jetbrains-scroll-hover-fanout.md b/.changeset/jetbrains-scroll-hover-fanout.md new file mode 100644 index 00000000000..fbaf73d908a --- /dev/null +++ b/.changeset/jetbrains-scroll-hover-fanout.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Smooth out chat scrolling in large JetBrains sessions by only refreshing hover state for the message under the pointer. diff --git a/.changeset/jetbrains-session-scroll-perf.md b/.changeset/jetbrains-session-scroll-perf.md new file mode 100644 index 00000000000..53cc72cf1a1 --- /dev/null +++ b/.changeset/jetbrains-session-scroll-perf.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve chat scrolling performance in large JetBrains sessions. diff --git a/.changeset/jetbrains-wide-preview-popovers.md b/.changeset/jetbrains-wide-preview-popovers.md new file mode 100644 index 00000000000..b679f1605e9 --- /dev/null +++ b/.changeset/jetbrains-wide-preview-popovers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Size edit and shell preview popovers to their content with a wider maximum width. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 04c7bdb56e2..d9e1cd84506 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -346,7 +346,7 @@ internal class SessionScroll( @RequiresEdt private fun layoutScroll() { - root.validate() + component.validate() } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index 2905bceb0fe..3b29734149d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -8,6 +8,7 @@ import java.awt.Container import java.awt.Dimension import java.awt.Insets import java.awt.LayoutManager +import java.util.IdentityHashMap /** * A vertical, width-aware layout manager for the session transcript. @@ -33,8 +34,12 @@ class SessionLayout( private val basePad: Insets = JBUI.emptyInsets(), ) : LayoutManager { + private val cache = IdentityHashMap() + override fun addLayoutComponent(name: String, comp: Component) = Unit - override fun removeLayoutComponent(comp: Component) = Unit + override fun removeLayoutComponent(comp: Component) { + cache.remove(comp) + } override fun preferredLayoutSize(parent: Container): Dimension { val ins = insets(parent) @@ -46,9 +51,7 @@ class SessionLayout( if (!first) h += gap(comp) first = false val child = bounds(ins, w, comp) - // Pre-size to available width so HTML panes reflow before we measure - comp.setSize(child.width, comp.height.coerceAtLeast(1)) - h += comp.preferredSize.height + h += measure(comp, child.width) } // w and h are already scaled px (child preferred heights + scaled gaps/insets) and // match what layoutContainer stacks, so return a plain Dimension. A JBDimension would @@ -68,14 +71,36 @@ class SessionLayout( if (!first) y += gap(comp) first = false val child = bounds(ins, w, comp) - // Fix width first so HTML reflows, then read the resulting height - comp.setSize(child.width, comp.height.coerceAtLeast(1)) - val h = comp.preferredSize.height + val h = measure(comp, child.width) comp.setBounds(child.left, y, child.width, h) y += h } } + /** + * Drop the cached measurement for [comp] so the next layout pass re-measures it. + * + * [measure] trusts `comp.isValid` as a freshness signal, which is safe only while `comp` is + * invalidated through this container. A child that is its own validate root (see + * [ai.kilocode.client.session.views.TurnView.isValidateRoot]) can be re-validated independently + * by `RepaintManager` — its `isValid` flips back to `true` before this layout re-measures it, + * so a content change that grows/shrinks its height would otherwise return a stale cached value. + * Callers that mutate such a child's content must forget it here so the cache stays honest. + */ + fun forget(comp: Component) { + cache.remove(comp) + } + + private fun measure(comp: Component, width: Int): Int { + val hit = cache[comp] + if (comp.isValid && hit?.width == width) return hit.height + // Pre-size to available width so HTML panes reflow before we measure. + comp.setSize(width, comp.height.coerceAtLeast(1)) + val h = comp.preferredSize.height + cache[comp] = Measured(width, h) + return h + } + private fun bounds(ins: Insets, width: Int, comp: Component): Bounds { val view = view(comp) ?: return Bounds(ins.left, width) if (view.sessionViewKind != SessionView.Kind.UserPrompt) return Bounds(ins.left, width) @@ -103,6 +128,8 @@ class SessionLayout( private fun view(comp: Component): SessionView? = comp as? SessionView private data class Bounds(val left: Int, val width: Int) + + private data class Measured(val width: Int, val height: Int) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 6774b94bb7e..a0dc4d7dfdc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -96,34 +96,37 @@ class SessionMessageListPanel( is SessionModelEvent.TurnRemoved -> onTurnRemoved(event.id) is SessionModelEvent.ContentAdded -> { - msgToView[event.messageId]?.upsertPart(event.content) - msgToTurn[event.messageId]?.syncCopyToolbars() - refresh() + if (msgToView[event.messageId]?.upsertPartChanged(event.content) == true) { + onContentChanged(event.messageId) + } } is SessionModelEvent.ContentUpdated -> { - msgToView[event.messageId]?.upsertPart(event.content) - msgToTurn[event.messageId]?.syncCopyToolbars() - refresh() + if (msgToView[event.messageId]?.upsertPartChanged(event.content) == true) { + onContentChanged(event.messageId) + } } is SessionModelEvent.ContentRemoved -> { - msgToView[event.messageId]?.removePart(event.contentId) - msgToTurn[event.messageId]?.syncCopyToolbars() - refresh() + if (msgToView[event.messageId]?.removePartChanged(event.contentId) == true) { + onContentChanged(event.messageId) + } } is SessionModelEvent.ContentDelta -> { if (event.created) return@addListener + if (event.delta.isEmpty()) return@addListener val handled = msgToView[event.messageId]?.appendDelta(event.contentId, event.delta) == true if (handled) { msgToTurn[event.messageId]?.syncCopyToolbars() + forgetTurn(event.messageId) return@addListener } val content = model.content(event.messageId, event.contentId) if (content != null) { - msgToView[event.messageId]?.upsertPart(content) - msgToTurn[event.messageId]?.syncCopyToolbars() + if (msgToView[event.messageId]?.upsertPartChanged(content) == true) { + onContentChanged(event.messageId) + } } } @@ -132,6 +135,7 @@ class SessionMessageListPanel( is SessionModelEvent.StateChanged -> { syncActive(event.state) + syncSettled(event.state) syncReverted() syncReverting(event.state) anchorFooter() @@ -222,6 +226,7 @@ class SessionMessageListPanel( tv.syncCopyToolbars() syncReverted() add(tv) + syncSettled() anchorFooter() refresh() } @@ -234,8 +239,7 @@ class SessionMessageListPanel( // Remove messages no longer in this turn for (id in prev) { if (id !in next) { - tv.removeMessage(id) - unregister(id) + if (tv.removeMessageChanged(id)) unregister(id) } } @@ -248,6 +252,7 @@ class SessionMessageListPanel( } tv.syncCopyToolbars() syncReverted() + syncSettled() refresh() } @@ -257,6 +262,7 @@ class SessionMessageListPanel( for (msgId in tv.messageIds()) unregister(msgId) remove(tv) Disposer.dispose(tv) + syncSettled() anchorFooter() refresh() } @@ -285,6 +291,7 @@ class SessionMessageListPanel( } syncActive(model.state) + syncSettled(model.state) syncReverted() syncReverting(model.state) banner?.update() @@ -313,6 +320,7 @@ class SessionMessageListPanel( revertingMessage = null removeAll() syncActive(model.state) + syncSettled(model.state) syncReverting(model.state) banner?.update() anchorFooter() @@ -375,6 +383,11 @@ class SessionMessageListPanel( for (mv in msgToView.values) mv.setHiddenQuestionTool(ref) } + private fun syncSettled(state: SessionState = model.state) { + val active = if (state.isBusy()) turnViews.values.lastOrNull() else null + for (view in turnViews.values) view.setSettled(view !== active) + } + /** * Re-insert [question], [permission], [login], and [progress] as the last children * so active views always render after all turn views, and progress is last. @@ -413,6 +426,25 @@ class SessionMessageListPanel( repaint() } + /** + * Handle a content mutation that changed an already-rendered message: sync the turn's copy + * toolbars, forget its cached height, then relayout. [forgetTurn] is essential when the update + * lands on a settled turn — a settled [TurnView] is its own validate root, so `RepaintManager` + * re-validates it independently and its `isValid` flag no longer signals the height change to + * [SessionLayout]'s measurement cache. + */ + private fun onContentChanged(messageId: String) { + msgToTurn[messageId]?.syncCopyToolbars() + forgetTurn(messageId) + refresh() + } + + /** Drop [SessionLayout]'s cached height for the turn holding [messageId] after its content changes. */ + private fun forgetTurn(messageId: String) { + val tv = msgToTurn[messageId] ?: return + (layout as? SessionLayout)?.forget(tv) + } + private fun hover(view: PartView, value: Boolean) { if (value) { val prev = hovered diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt index a0e0daf0a55..39e8d60de21 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/popup/HeaderPopup.kt @@ -1,14 +1,20 @@ package ai.kilocode.client.session.ui.popup +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.openapi.Disposable +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBTextArea import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Color +import java.awt.Component import java.awt.Container import java.awt.Dimension +import java.awt.Insets import javax.swing.JComponent import javax.swing.JEditorPane import javax.swing.JPanel +import javax.swing.JScrollPane class HeaderPopupRequest( val anchor: JComponent, @@ -20,11 +26,15 @@ class HeaderPopupBody( component: JComponent, val disposable: Disposable, val background: Color, + maxWidth: Int = SessionUiStyle.View.Popup.MAX_WIDTH, ) { - val component: JComponent = HeaderPopupPanel(component) + val component: JComponent = HeaderPopupPanel(component, JBUI.scale(maxWidth)) } -private class HeaderPopupPanel(private val child: JComponent) : JPanel(BorderLayout()) { +private class HeaderPopupPanel( + private val child: JComponent, + private val maxWidth: Int, +) : JPanel(BorderLayout()) { init { // Transparent so the balloon fill shows uniformly behind nested popup content. isOpaque = false @@ -32,14 +42,32 @@ private class HeaderPopupPanel(private val child: JComponent) : JPanel(BorderLay } override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - val cap = JBUI.scale(350) - val width = size.width.takeIf { it > 0 }?.coerceAtMost(cap) ?: cap + val width = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth fit(child, width) - val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(450)) + val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) return Dimension(width, height) } + private fun contentWidth(item: Component): Int = when (item) { + is EditorTextField -> item.preferredSize.width + is JBTextArea -> item.preferredSize.width + is JEditorPane -> item.preferredSize.width + is JScrollPane -> { + val view = item.viewport?.view?.let(::contentWidth) ?: 0 + view + horiz(item.insets) + horiz(item.viewportBorder?.getBorderInsets(item)) + } + // JComponent is a Container, so leaf components (labels, buttons, icons) reach here with no + // children — fall back to their own preferred width instead of measuring an empty child set. + is Container -> { + val kids = item.components + if (kids.isEmpty()) (item as? JComponent)?.preferredSize?.width ?: 0 + else (kids.maxOfOrNull(::contentWidth) ?: 0) + horiz((item as? JComponent)?.insets) + } + else -> 0 + } + + private fun horiz(insets: Insets?): Int = (insets?.left ?: 0) + (insets?.right ?: 0) + private fun fit(item: JComponent, width: Int) { if (width <= 0) return // JBHtmlPane derives wrapped preferred height from the current width, not just HTML content. 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 d94bbe75266..cb4bc8cb6ce 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 @@ -37,6 +37,12 @@ object SessionUiStyle { const val BODY_EXTRA_HEIGHT = 16 } + object Popup { + const val MAX_WIDTH = 350 + const val WIDE_MAX_WIDTH = MAX_WIDTH * 2 + const val MAX_HEIGHT = 450 + } + internal const val BORDER_DELTA = 80 internal const val HOVER_BORDER_ALPHA = 0.18f internal const val HOVER_FILL_ALPHA = 0.10f @@ -169,6 +175,7 @@ object SessionUiStyle { object Tool { const val BODY_LINES = 15 const val TASK_LINES = 10 + const val DIFF_LINES = 20 const val PREVIEW_LIMIT = 20_000 fun pending(): Color = UiStyle.Colors.weak() 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 b043bfbf77f..aed735293b8 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 @@ -7,6 +7,7 @@ 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 +import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.model.ToolExecState @@ -110,7 +111,12 @@ class MessageView( /** Add or update the renderer for [content]. */ @RequiresEdt fun upsertPart(content: Content) { - if (content is StepFinish) return + upsertPartChanged(content) + } + + @RequiresEdt + fun upsertPartChanged(content: Content): Boolean { + if (content is StepFinish) return false if (isHidden(content)) { if (isPromptMention(content)) syncPromptMentions() // Remove any stale view for this content so it disappears when suppressed @@ -122,7 +128,7 @@ class MessageView( stale.remove(content.id) if (!stale.isEmpty()) { refresh() - return + return true } attachments = null } @@ -131,14 +137,15 @@ class MessageView( Disposer.dispose(stale) syncBorder() refresh() + return true } - return + return false } val id = aliases[content.id] if (id != null && content is Reasoning) { - updateAlias(content, id) + if (!updateAlias(content, id)) return false refresh() - return + return true } if (id != null) { aliases.remove(content.id) @@ -149,20 +156,24 @@ class MessageView( if (existing is PromptAttachmentView && content is FileAttachment) { existing.upsert(content) refresh() - return + return true } if (ViewFactory.shouldReplace(existing, content)) { replacePart(content, existing) - return + return true + } + if (content is Text && existing is TextView && existing !is PromptView && existing.markdown() == content.content.toString()) { + return false } existing.update(content) syncPromptToolbar() refresh() - return + return true } addPart(content) syncBorder() refresh() + return true } @RequiresEdt @@ -203,14 +214,15 @@ class MessageView( } @RequiresEdt - private fun updateAlias(content: Reasoning, id: String) { - val view = parts[id] as? ReasoningView ?: return + private fun updateAlias(content: Reasoning, id: String): Boolean { + val view = parts[id] as? ReasoningView ?: return false val prev = sources[content.id].orEmpty() val next = content.content.toString() val delta = if (next.startsWith(prev)) next.removePrefix(prev) else next sources[content.id] = next - if (delta.isEmpty()) return + if (delta.isEmpty()) return false view.update(merged(view, content, delta)) + return true } private fun merged(view: ReasoningView, content: Reasoning, delta: String) = Reasoning(view.contentId).also { @@ -242,16 +254,21 @@ class MessageView( /** Remove the renderer for [contentId] if present. */ @RequiresEdt fun removePart(contentId: String) { + removePartChanged(contentId) + } + + @RequiresEdt + fun removePartChanged(contentId: String): Boolean { if (aliases.remove(contentId) != null) { sources.remove(contentId) - return + return true } - val view = parts.remove(contentId) ?: return + val view = parts.remove(contentId) ?: return false if (view is PromptAttachmentView) { view.remove(contentId) if (!view.isEmpty()) { refresh() - return + return true } attachments = null } @@ -262,6 +279,7 @@ class MessageView( Disposer.dispose(view) syncBorder() refresh() + return true } /** @@ -335,6 +353,7 @@ class MessageView( /** Append a streaming delta to the renderer for [contentId]. */ @RequiresEdt fun appendDelta(contentId: String, delta: String): Boolean { + if (delta.isEmpty()) return false val id = aliases[contentId] if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta val part = parts[id ?: contentId] ?: return false diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index d64f85791cb..b4d8684004f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.registry.Registry import com.intellij.util.concurrency.annotations.RequiresEdt import javax.swing.JComponent @@ -38,6 +39,7 @@ class TurnView( ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() + private var settled = true override val sessionViewKind = SessionView.Kind.Default @@ -48,6 +50,17 @@ class TurnView( isOpaque = false } + @RequiresEdt + fun setSettled(value: Boolean) { + if (settled == value) return + settled = value + revalidate() + } + + override fun isValidateRoot(): Boolean { + return Registry.`is`("kilo.session.validateRoots", true) && settled + } + /** 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, openAttachment, resize, repo, hover, revert) @@ -60,11 +73,17 @@ class TurnView( /** Remove the [MessageView] for [msgId] if present. */ fun removeMessage(msgId: String) { - val view = messages.remove(msgId) ?: return + removeMessageChanged(msgId) + } + + @RequiresEdt + fun removeMessageChanged(msgId: String): Boolean { + val view = messages.remove(msgId) ?: return false remove(view) Disposer.dispose(view) syncCopyToolbars() revalidate() + return true } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 68c06d142a3..bc6cf2b3140 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.views.tool.EditToolView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.ReadToolView import ai.kilocode.client.session.views.tool.SearchToolView @@ -54,6 +55,7 @@ object ViewFactory { GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) + EditToolView.canRender(content) -> EditToolView(content, openFile, selection = selection) TaskToolView.canRender(content) -> TaskToolView(content, selection = selection) else -> ToolView(content, selection = selection) } @@ -100,6 +102,8 @@ object ViewFactory { if (view !is SearchToolView && SearchToolView.canRender(content)) return true if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content) if (view is ToolView && ReadToolView.canRender(content)) return true + if (view is EditToolView) return !EditToolView.canRender(content) || QuestionResultView.canRender(content) + if (view is ToolView && EditToolView.canRender(content)) return true if (view is TaskToolView) return !TaskToolView.canRender(content) || QuestionResultView.canRender(content) if (view !is TaskToolView && TaskToolView.canRender(content)) return true if (view is ToolView) return QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt new file mode 100644 index 00000000000..865e2d42f40 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/EditToolView.kt @@ -0,0 +1,276 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolKind +import ai.kilocode.client.session.ui.popup.HeaderPopupBody +import ai.kilocode.client.session.ui.popup.HeaderPopupRequest +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.telemetry.Telemetry +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** + * Renders write tools (edit/write/apply_patch) with a Read-style header — an "Edit" title and a + * clickable file link — plus a diff-stat changes tag. The expandable body and the collapsed hover + * popup both render the unified diff via the shared markdown code editor, which colors it as a diff. + */ +class EditToolView( + tool: Tool, + private val openFile: SessionFileOpener = { _, _ -> }, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, openFile), + private var body: EditBody = editBody(tool, selection, openFile), +) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + private var multi = editFiles(tool).size > 1 + private val badge = DiffStatBadge(0, 0) + private val filesTag = JBLabel().apply { + foreground = UiStyle.Colors.weak() + font = JBFont.small() + border = JBUI.Borders.emptyRight(SessionUiStyle.View.Layout.HORIZONTAL_PADDING) + isVisible = false + } + + init { + body.parent = this + parts.controls.add(filesTag) + parts.controls.add(badge) + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge) + applyStyle(style) + sync() + } + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) } + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + body.applyStyle(style) + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + (body.panel()?.preferredSize?.height ?: 0) + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = if (!expandable()) collapse() else false + changed = swapBody() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + /** Rebuild the body delegate when a streaming tool crosses the single/multi-file boundary. */ + @RequiresEdt + private fun swapBody(): Boolean { + val next = editFiles(item).size > 1 + if (next == multi) return false + multi = next + val expanded = isExpanded() + discardBody() + body.disposeBody() + body = editBody(item, selection, openFile).also { it.parent = this } + if (expanded) expand() + return true + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun bodyText(): String = editDiff(item) + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt + fun diffStat(): Pair = diffStat(item) + @RequiresEdt + internal fun badgeVisible() = badge.isVisible + @RequiresEdt + internal fun filesTagVisible() = filesTag.isVisible + @RequiresEdt + internal fun filesTagText() = filesTag.text + @RequiresEdt + internal fun linkVisible() = parts.link.isVisible + @RequiresEdt + internal fun linkLabel() = parts.label + @RequiresEdt + internal fun linkHref() = parts.href + @RequiresEdt + internal fun linkTooltip() = parts.link.toolTipText + @RequiresEdt + internal fun openLink() = parts.openLink() + @RequiresEdt + internal fun bodyCreated() = body.created() + @RequiresEdt + internal fun bodyVisible() = body.attached(this) + @RequiresEdt + internal fun markdown() = body.markdown() ?: diffMarkdown(item) + @RequiresEdt + internal fun codeEditors(): List = body.codeEditors() + + @RequiresEdt + override fun headerPopup(): HeaderPopupRequest? { + if (isExpanded()) return null + if (editDiff(item).isBlank()) return null + return HeaderPopupRequest(row, build = { buildPopupBody() }) { + Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "edit")) + } + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.transcriptFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = body.applyStyle(style) || changed + if (changed) refresh() + } + + private fun expandable(): Boolean = + editDiff(item).isNotBlank() || output(item).isNotBlank() || !item.error.isNullOrBlank() + + private fun sync(): Boolean { + val expand = expandable() + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + val count = editFiles(item).size + val titleText = if (count > 1) KiloBundle.message("session.part.tool.patch") else title(item) + changed = setText(parts.title, titleText) || changed + val path = if (count > 1) null else editPath(item) + changed = setFileTarget(parts, path, if (path == null) "" else tail(path)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + changed = syncFilesTag(count) || changed + changed = syncBadge() || changed + return changed + } + + private fun syncFilesTag(count: Int): Boolean { + val show = count > 1 + var changed = setVisible(filesTag, show) + if (show) changed = setText(filesTag, KiloBundle.message("session.part.tool.edit.files", count)) || changed + return changed + } + + private fun syncBadge(): Boolean { + val (added, removed) = diffStat(item) + val show = added > 0 || removed > 0 + val changed = setVisible(badge, show) + if (show) badge.update(added, removed) + return changed + } + + private fun syncBody(): Boolean = body.update(item) + + @RequiresEdt + private fun buildPopupBody(): HeaderPopupBody { + val owner = Disposer.newDisposable("Edit popup body") + val popup = popupBody(item, selection, openFile).also { it.parent = owner } + // mount() already renders the current item (ToolMarkdownBody.mount calls update; PatchBody.mount + // calls rebuild and sets its signature), so a follow-up update() here would be a no-op. + val panel = popup.mount(item) + popup.applyStyle(style) + return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) + } + + override fun dumpLabel() = "EditToolView#$contentId(${labelText()})" + + companion object { + fun canRender(tool: Tool) = tool.kind == ToolKind.WRITE + } +} + +/** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */ +private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = + if (editFiles(tool).size > 1) PatchBody(selection, openFile) else diffBody(selection) + +private fun popupBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody = + if (editFiles(tool).size > 1) PatchBody(selection, openFile, POPUP_OPTS) else popupDiffBody(selection) + +private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Bottom, + maxLines = SessionUiStyle.View.Tool.DIFF_LINES, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + selection, + render = ::diffMarkdown, +) + +private fun popupDiffBody(selection: SessionSelection?) = ToolMarkdownBody( + POPUP_OPTS, + selection, + render = ::diffMarkdown, +) + +private val POPUP_OPTS = MdCodeBlockOptions( + border = MdCodeBlockBorder.None, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, +) + +/** + * Diff body markdown: per-file sections when an apply_patch touched multiple files, otherwise the + * single unified patch, falling back to the tool output/error when no diff is available. + */ +@RequiresEdt +internal fun diffMarkdown(tool: Tool): String { + val files = editFiles(tool) + if (files.count { it.patch.isNotBlank() } > 1) return multiFileDiffMarkdown(files) + val diff = editDiff(tool) + if (diff.isNotBlank()) return patchMarkdown(diff) + val body = plainBody(tool) + if (body.isBlank()) return "" + val fence = fence(body) + return buildString { + append(fence).append('\n') + append(body) + if (!body.endsWith('\n')) append('\n') + append(fence) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt new file mode 100644 index 00000000000..85cd7f2cc5b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/PatchBody.kt @@ -0,0 +1,191 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.SessionFileOpener +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.md.MdCodeBlockBorder +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Component +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +/** + * Body surface shared by the single-file markdown diff ([ToolMarkdownBody]) and the multi-file + * apply_patch view ([PatchBody]), so [EditToolView] can hold either behind one type and swap between + * them when a streaming tool crosses the single/multi boundary. + */ +interface EditBody { + var parent: Disposable? + + @RequiresEdt fun mount(tool: Tool): JComponent + @RequiresEdt fun created(): Boolean + @RequiresEdt fun panel(): JComponent? + @RequiresEdt fun attached(host: Component): Boolean + @RequiresEdt fun update(tool: Tool): Boolean + @RequiresEdt fun applyStyle(style: SessionEditorStyle): Boolean + @RequiresEdt fun markdown(): String? + @RequiresEdt fun codeEditors(): List + @RequiresEdt fun disposeBody() +} + +/** + * Renders an apply_patch that touched several files as one section per file: a clickable filename + * link (same chrome as the Read/Edit header link) plus a per-file changes badge, left-aligned to the + * diff's own text inset, followed by that file's unified diff. Sections are rebuilt as a group when + * the underlying file set changes, matching the retained-Swing rebuild-on-add/remove convention. + */ +class PatchBody( + private val selection: SessionSelection?, + private val openFile: SessionFileOpener, + private val opts: MdCodeBlockOptions = DIFF_OPTS, +) : EditBody { + override var parent: Disposable? = null + + private var root: Stack? = null + private var owner: Disposable? = null + private val views = mutableListOf() + private val links = mutableListOf() + private var style = SessionEditorStyle.current() + private var signature = "" + + @RequiresEdt + override fun mount(tool: Tool): JComponent { + root?.let { return it } + val panel = Stack.vertical() + root = panel + rebuild(tool) + return panel + } + + @RequiresEdt + override fun created(): Boolean = root != null + + @RequiresEdt + override fun panel(): JComponent? = root + + @RequiresEdt + override fun attached(host: Component): Boolean = root?.parent === host + + @RequiresEdt + override fun update(tool: Tool): Boolean { + if (root == null) return false + if (signatureOf(tool) == signature) return false + rebuild(tool) + return true + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle): Boolean { + this.style = style + var changed = false + views.forEach { changed = applyMd(it) || changed } + links.forEach { if (it.font != style.transcriptFont) { it.font = style.transcriptFont; changed = true } } + return changed + } + + @RequiresEdt + override fun markdown(): String? { + if (views.isEmpty()) return null + return views.joinToString("\n\n") { it.markdown() } + } + + @RequiresEdt + override fun codeEditors(): List = views.flatMap { view -> + (view.component as? JPanel)?.components + ?.filterIsInstance() + ?.mapNotNull { it.viewport.view as? EditorTextField } + ?: emptyList() + } + + @RequiresEdt + override fun disposeBody() { + val panel = root + owner?.let(Disposer::dispose) + owner = null + views.clear() + links.clear() + panel?.removeAll() + signature = "" + } + + @RequiresEdt + private fun rebuild(tool: Tool) { + val panel = root ?: return + val parent = parent ?: error("Patch body has no parent") + disposeBody() + val disposable = Disposer.newDisposable("Patch body") + Disposer.register(parent, disposable) + owner = disposable + editFiles(tool).filter { it.patch.isNotBlank() }.forEachIndexed { index, file -> + if (index > 0) panel.gap(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP)) + panel.next(header(file)) + panel.gap(UiStyle.Gap.sm()) + val md = MdViewFactory.create(style, selection, MdCodeBlockFactory.default(opts)) + Disposer.register(disposable, md) + applyMd(md) + md.set(patchMarkdown(file.patch)) + views.add(md) + panel.next(md.component) + } + signature = signatureOf(tool) + panel.revalidate() + panel.repaint() + } + + private fun signatureOf(tool: Tool): String = editFiles(tool) + .joinToString("\u0000") { "${it.path}\u0001${it.additions}\u0001${it.deletions}\u0001${it.patch}" } + + @RequiresEdt + private fun header(file: EditFileChange): JComponent { + val link = FileLinkLabel(openFile).apply { + foreground = UiStyle.Colors.fg() + font = style.transcriptFont + setTarget(file.path, tail(file.path)) + isVisible = true + } + links.add(link) + val row = Stack.horizontal(UiStyle.Gap.sm()) + .next(link) + .next(DiffStatBadge(file.additions, file.deletions)) + return JBUI.Panels.simplePanel(row).apply { + isOpaque = false + border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING) + } + } + + private fun applyMd(md: MdView): Boolean { + val before = md.font + md.applyStyle(style) + md.font = style.editorFont + md.foreground = style.editorForeground + md.background = style.editorBackground + md.preBg = style.editorBackground + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + return before != md.font + } + + private companion object { + val DIFF_OPTS = MdCodeBlockOptions( + border = MdCodeBlockBorder.Bottom, + maxLines = SessionUiStyle.View.Tool.DIFF_LINES, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt index 2a205091884..c9b80039d11 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -96,6 +96,8 @@ class ReadToolView( @RequiresEdt internal fun linkHref() = parts.href @RequiresEdt + internal fun linkTooltip() = parts.link.toolTipText + @RequiresEdt internal fun openLink() = parts.openLink() @RequiresEdt @@ -129,24 +131,9 @@ class ReadToolView( private fun syncSubtitle(): Boolean { val target = target(item)?.takeIf { it.type == "file" } - if (target != null) { - var changed = false - if (parts.href != target.path) { - parts.href = target.path - changed = true - } - changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed - changed = show(parts, true) || changed - return changed - } - - var changed = false - if (parts.href != null) { - parts.href = null - changed = true - } + if (target != null) return setFileTarget(parts, target.path, tail(target.path)) + var changed = setFileTarget(parts, null, "") changed = setText(parts.sub, subtitle(item)) || changed - changed = show(parts, false) || changed return changed } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index de4119773b1..a14bd9f5262 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -14,12 +14,11 @@ import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.client.ui.md.hybrid.MdTerminal import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.actionSystem.UiDataProvider -import com.intellij.openapi.Disposable -import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBScrollPane @@ -34,8 +33,8 @@ class ShellToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool), - private val holder: ShellHolder = ShellHolder(tool, selection), -) : SecondarySessionPartView(parts.header, { holder.body().panel }), UiDataProvider { + private val body: ToolMarkdownBody = shellBody(selection), +) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider { override val contentId: String = tool.id @@ -43,14 +42,14 @@ class ShellToolView( private var style = SessionEditorStyle.current() init { - holder.parent = this + body.parent = this bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) applyStyle(style) sync() } override fun uiDataSnapshot(sink: DataSink) { - selection?.provideCopy(sink) { holder.shell?.markdown() ?: fallbackText() } + selection?.provideCopy(sink) { body.markdown() ?: fallbackText() } } private fun fallbackText() = ShellContent(item).body @@ -60,7 +59,7 @@ class ShellToolView( val changed = super.expand() if (!changed) return false syncBody() - holder.shell?.applyStyle(style) + body.applyStyle(style) return true } @@ -68,7 +67,7 @@ class ShellToolView( override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size - val height = row.preferredSize.height + (holder.shell?.panel?.preferredSize?.height ?: 0) + val height = row.preferredSize.height + (body.panel()?.preferredSize?.height ?: 0) return Dimension(size.width, minOf(size.height, height)) } @@ -105,16 +104,16 @@ class ShellToolView( fun hasToggle(): Boolean = arrow.isVisible @RequiresEdt - internal fun bodyCreated() = holder.shell != null + internal fun bodyCreated() = body.created() @RequiresEdt - internal fun bodyVisible() = holder.shell?.panel?.parent === this + internal fun bodyVisible() = body.attached(this) @RequiresEdt - internal fun markdown() = holder.shell?.markdown() ?: ShellContent(item).markdown + internal fun markdown() = body.markdown() ?: ShellContent(item).markdown @RequiresEdt - internal fun codeEditors(): List = holder.shell?.codeEditors() ?: emptyList() + internal fun codeEditors(): List = body.codeEditors() @RequiresEdt internal fun commandFont() = codeEditors().firstOrNull()?.font ?: style.editorFont @@ -138,10 +137,10 @@ class ShellToolView( internal fun controlCount() = if (arrow.isVisible) 1 else 0 @RequiresEdt - internal fun mdComponent() = holder.shell?.mdComponent() + internal fun mdComponent() = body.panel() @RequiresEdt - internal fun horizontalPolicy() = holder.shell?.scrolls()?.firstOrNull()?.horizontalScrollBarPolicy + internal fun horizontalPolicy() = body.scrolls().firstOrNull()?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER @RequiresEdt @@ -161,7 +160,7 @@ class ShellToolView( changed = setFont(parts.sub, style.transcriptFont) || changed changed = setFont(parts.link, style.smallEditorFont) || changed changed = setFont(parts.state, style.smallEditorFont) || changed - holder.shell?.let { changed = it.applyStyle(style) || changed } + changed = body.applyStyle(style) || changed if (changed) refresh() } @@ -181,10 +180,7 @@ class ShellToolView( return changed } - private fun syncBody(): Boolean { - val body = holder.shell ?: return false - return body.update(item) - } + private fun syncBody(): Boolean = body.update(item) @RequiresEdt private fun buildPopupBody(cmd: String): HeaderPopupBody { @@ -208,7 +204,7 @@ class ShellToolView( md.component.border = JBUI.Borders.empty() md.set(popupMd(formatCommand(cmd))) padPopup(md.component) - return HeaderPopupBody(md.component, md, style.editorBackground) + return HeaderPopupBody(md.component, md, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH) } override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})" @@ -234,94 +230,26 @@ private fun padPopup(root: JComponent) { private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad) -class ShellHolder( - private val tool: Tool, - private val selection: SessionSelection?, -) { - var parent: Disposable? = null - var shell: ShellBody? = null - - @RequiresEdt - fun body(): ShellBody { - val current = shell - if (current != null) return current - val owner = parent ?: error("Shell holder has no parent") - return ShellBody(tool, selection, owner).also { - shell = it - Disposer.register(owner, it) - } - } -} - -class ShellBody( - tool: Tool, - selection: SessionSelection?, - parent: Disposable, -) : Disposable { - private val md = MdViewFactory.create( - SessionEditorStyle.current(), - selection, - MdCodeBlockFactory.default( - MdCodeBlockOptions( - border = MdCodeBlockBorder.Bottom, - maxLines = 15, - verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, - editorOnly = true, - ), - ), - ) - val panel = md.component - - init { - Disposer.register(parent, md) - applyStyle(SessionEditorStyle.current()) - update(tool) - } - - @RequiresEdt - fun update(tool: Tool): Boolean { - val content = ShellContent(tool) - if (md.markdown() == content.markdown) return false - md.set(content.markdown) - styleShell() - return true - } - - @RequiresEdt - fun applyStyle(style: SessionEditorStyle): Boolean { - val before = md.font - md.applyStyle(style) - md.font = style.transcriptFont - md.foreground = style.editorForeground - md.background = style.editorBackground - md.preBg = style.editorBackground - md.codeFont = style.editorFamily - md.component.border = JBUI.Borders.empty() - styleShell() - return before != md.font - } - - @RequiresEdt - private fun styleShell() { - val root = md.component as? JPanel ?: return - root.components.filterIsInstance().forEach { - it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING) - } +private fun shellBody(selection: SessionSelection?) = ToolMarkdownBody( + MdCodeBlockOptions( + border = MdCodeBlockBorder.Bottom, + maxLines = 15, + verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, + editorOnly = true, + ), + selection, + render = { ShellContent(it).markdown }, + font = SessionEditorStyle::transcriptFont, + chrome = ::styleShellHtml, +) + +/** Pads the left edge of shell section headers ("Command"/"Output") to line up with code text. */ +@RequiresEdt +private fun styleShellHtml(md: MdView) { + val root = md.component as? JPanel ?: return + root.components.filterIsInstance().forEach { + it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING) } - - @RequiresEdt - fun markdown() = md.markdown() - - @RequiresEdt - fun mdComponent() = md.component - - @RequiresEdt - fun scrolls(): List = (md.component as? JPanel)?.components?.filterIsInstance() ?: emptyList() - - @RequiresEdt - fun codeEditors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } - - override fun dispose() = Unit } private data class ShellContent( @@ -406,9 +334,4 @@ private fun StringBuilder.section(title: String, text: String, lang: String) { append(fence) } -private fun fence(text: String): String { - val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0 - return "`".repeat(maxOf(3, size + 1)) -} - private fun clean(text: String): String = MdTerminal.strip(MdTerminal.reduce(text, keepSgr = false)) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt new file mode 100644 index 00000000000..3b5e1830e9d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolMarkdownBody.kt @@ -0,0 +1,102 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.ui.md.MdCodeBlockFactory +import ai.kilocode.client.ui.md.MdCodeBlockOptions +import ai.kilocode.client.ui.md.MdView +import ai.kilocode.client.ui.md.MdViewFactory +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Component +import java.awt.Font +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * A markdown-backed tool body (unified diff, shell transcript, ...) that is built lazily on first + * expansion and then mutated in place. Shared by [ShellToolView] and [EditToolView] so the + * lazy-init, styling, disposal, and editor-lookup logic lives in one place instead of being + * duplicated per tool. + * + * [render] turns the current [Tool] into the markdown to display, [font] picks the body font from + * the active style, and [chrome] applies any per-view tweaks after the markdown is (re)built. + */ +class ToolMarkdownBody( + private val opts: MdCodeBlockOptions, + private val selection: SessionSelection?, + private val render: (Tool) -> String, + private val font: (SessionEditorStyle) -> Font = SessionEditorStyle::editorFont, + private val chrome: (MdView) -> Unit = {}, +) : EditBody { + override var parent: Disposable? = null + private var view: MdView? = null + + /** Builds the body on first call, wiring it into [parent]'s disposable tree, then returns it. */ + @RequiresEdt + override fun mount(tool: Tool): JComponent { + view?.let { return it.component } + val owner = parent ?: error("Tool markdown body has no parent") + val md = MdViewFactory.create(SessionEditorStyle.current(), selection, MdCodeBlockFactory.default(opts)) + Disposer.register(owner, md) + view = md + applyStyle(SessionEditorStyle.current()) + update(tool) + return md.component + } + + @RequiresEdt + override fun created(): Boolean = view != null + + @RequiresEdt + override fun panel(): JComponent? = view?.component + + @RequiresEdt + override fun attached(host: Component): Boolean = view?.component?.parent === host + + @RequiresEdt + override fun update(tool: Tool): Boolean { + val md = view ?: return false + val value = render(tool) + if (md.markdown() == value) return false + md.set(value) + chrome(md) + return true + } + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle): Boolean { + val md = view ?: return false + val before = md.font + md.applyStyle(style) + md.font = font(style) + md.foreground = style.editorForeground + md.background = style.editorBackground + md.preBg = style.editorBackground + md.codeFont = style.editorFamily + md.component.border = JBUI.Borders.empty() + chrome(md) + return before != md.font + } + + @RequiresEdt + override fun markdown(): String? = view?.markdown() + + @RequiresEdt + fun scrolls(): List = + (view?.component as? JPanel)?.components?.filterIsInstance() ?: emptyList() + + @RequiresEdt + override fun codeEditors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + @RequiresEdt + override fun disposeBody() { + view?.let(Disposer::dispose) + view = null + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index f9a9e3e6763..cbc8bf09753 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.ToolKind import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -36,8 +37,14 @@ import com.intellij.ui.components.JBTextArea import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import com.intellij.xml.util.XmlStringUtil +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import java.awt.BorderLayout -import java.awt.CardLayout import java.awt.Color import java.awt.Cursor import java.awt.Dimension @@ -59,18 +66,17 @@ class ToolParts( val glyph: JBLabel, val title: JBLabel, val sub: JBLabel, - val link: JBLabel, + val link: FileLinkLabel, val slot: JPanel, val state: JBLabel, val center: JPanel, val controls: JComponent, - private val open: SessionFileOpener? = null, val extra: JBLabel? = null, val targets: List = emptyList(), private val mode: ToolBodyMode = ToolBodyMode.EDITOR, ) { - var href: String? = null - var label: String = "" + val href: String? get() = link.href + val label: String get() = link.label private var body: ToolBody? = null val text: JBTextArea? @@ -93,8 +99,7 @@ class ToolParts( @RequiresEdt fun openLink(anchor: RelativePoint? = null) { - val value = href ?: return - open?.invoke(value, anchor) + link.openLink(anchor) } @RequiresEdt @@ -109,6 +114,52 @@ class ToolParts( } } +class FileLinkLabel( + private val open: SessionFileOpener? = null, +) : JBLabel() { + var href: String? = null + private set + var label: String = "" + private set + + init { + isVisible = false + isFocusable = false + foreground = UiStyle.Colors.fg() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + setRequestFocusEnabled(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + openLink(RelativePoint(this@FileLinkLabel, Point(width / 2, height))) + } + }) + } + + @RequiresEdt + fun setTarget(path: String?, text: String): Boolean { + val next = single(text.ifBlank { path.orEmpty() }) + val value = if (next.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(next)}") + var changed = false + if (href != path) { + href = path + toolTipText = path + changed = true + } + if (label != next || this.text != value) { + label = next + this.text = value + changed = true + } + return changed + } + + @RequiresEdt + fun openLink(anchor: RelativePoint? = null) { + val value = href ?: return + open?.invoke(value, anchor) + } +} + class ToolBody private constructor( val area: JBTextArea?, val ed: EditorTextField?, @@ -345,36 +396,20 @@ private class ToolField(value: String, private var style: SessionEditorStyle, pr } } -private const val SUB_CARD = "sub" -private const val LINK_CARD = "link" - @RequiresEdt internal fun toolParts( tool: Tool, openFile: SessionFileOpener? = null, mode: ToolBodyMode = ToolBodyMode.TEXT, ): ToolParts { - lateinit var parts: ToolParts val glyph = JBLabel() val title = clip(JBLabel()) val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } - val link = clip(JBLabel()).apply { - isVisible = false - isFocusable = false - foreground = UiStyle.Colors.fg() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - setRequestFocusEnabled(false) - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - parts.openLink(RelativePoint(this@apply, Point(width / 2, 0))) - } - }) - } - val slot = JPanel(CardLayout()).apply { - isOpaque = false + val link = clip(FileLinkLabel(openFile)) + val slot = Stack.fitHorizontal().apply { minimumSize = Dimension(0, minimumSize.height) - add(sub, SUB_CARD) - add(link, LINK_CARD) + next(sub) + next(link) } val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply { @@ -390,7 +425,7 @@ internal fun toolParts( add(center, BorderLayout.CENTER) add(controls, BorderLayout.EAST) } - parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile, mode = mode) + val parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, mode = mode) return parts.also { controls.add(it.state) } @@ -406,12 +441,11 @@ internal fun searchParts(count: Int): ToolParts { foreground = UiStyle.Colors.fg() } } - val link = clip(JBLabel()).apply { isVisible = false } - val slot = JPanel(CardLayout()).apply { - isOpaque = false + val link = clip(FileLinkLabel()) + val slot = Stack.fitHorizontal().apply { minimumSize = Dimension(0, minimumSize.height) - add(sub, SUB_CARD) - add(link, LINK_CARD) + next(sub) + next(link) } val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() } val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } @@ -449,9 +483,10 @@ internal fun icon(tool: Tool) = when (tool.name) { else -> SessionViewIcons.mcp } -internal fun title(tool: Tool) = when (tool.name) { - "read" -> KiloBundle.message("session.part.tool.read") - "bash" -> KiloBundle.message("session.part.tool.shell") +internal fun title(tool: Tool) = when { + tool.name == "read" -> KiloBundle.message("session.part.tool.read") + tool.name == "bash" -> KiloBundle.message("session.part.tool.shell") + tool.kind == ToolKind.WRITE -> KiloBundle.message("session.part.tool.edit") else -> toolTitle(tool) } @@ -477,17 +512,18 @@ internal fun setTargetText(label: JBLabel, text: String): Boolean { return true } +/** + * Shows [path] as a clickable file link in the header slot, or clears the link when [path] is null. + * Shared by [ai.kilocode.client.session.views.tool.ReadToolView] and + * [ai.kilocode.client.session.views.tool.EditToolView] so both render file targets identically. + */ @RequiresEdt -internal fun setLinkText(parts: ToolParts, text: String): Boolean { - val label = single(text) - val value = if (label.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(label)}") - if (parts.label == label && parts.link.text == value) return false - parts.label = label - parts.link.text = value - return true +internal fun setFileTarget(parts: ToolParts, path: String?, label: String): Boolean { + val changed = parts.link.setTarget(path, label) + return show(parts, path != null) || changed } -private fun clip(label: JBLabel): JBLabel = label.apply { +private fun clip(label: T): T = label.apply { minimumSize = Dimension(0, minimumSize.height) } @@ -504,9 +540,10 @@ private fun single(text: String): String = text.lineSequence() @RequiresEdt internal fun show(parts: ToolParts, link: Boolean): Boolean { - if (parts.link.isVisible == link && parts.sub.isVisible != link) return false - (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) - return true + var changed = false + changed = setVisible(parts.link, link) || changed + changed = setVisible(parts.sub, !link) || changed + return changed } internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text @@ -704,6 +741,161 @@ private fun toolSubtitle(tool: Tool): String { return listOfNotNull(base).plus(args).joinToString(" ") } +/** File path targeted by a write tool, preferring the most specific resolvable path. */ +internal fun editPath(tool: Tool): String = editPaths(tool).maxWithOrNull( + compareBy({ OSAgnosticPathUtil.isAbsolute(it) }, { depth(it) }), +) ?: tool.name + +private fun editPaths(tool: Tool): List { + val direct = listOf(tool.input["filePath"], tool.input["path"]) + val diff = listOfNotNull(editFile(parseJsonObject(tool.metadata["filediff"]))) + val files = parseJsonArray(tool.metadata["files"])?.mapNotNull { editFile(it.jsonObject) } ?: emptyList() + return (direct + diff + files + listOf(tool.title, tool.name)) + .mapNotNull { it?.takeIf { value -> value.isNotBlank() } } +} + +private fun editFile(obj: JsonObject?): String? = listOf("filePath", "path", "file", "relativePath") + .firstNotNullOfOrNull { key -> obj?.get(key)?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } } + +private fun depth(path: String): Int = path.count { it == '/' || it == '\\' } + +private val DIFF_JSON = Json { ignoreUnknownKeys = true; isLenient = true } + +private fun parseJsonObject(raw: String?): JsonObject? = + raw?.takeIf { it.isNotBlank() }?.let { runCatching { DIFF_JSON.parseToJsonElement(it).jsonObject }.getOrNull() } + +private fun parseJsonArray(raw: String?): JsonArray? = + raw?.takeIf { it.isNotBlank() }?.let { runCatching { DIFF_JSON.parseToJsonElement(it) as? JsonArray }.getOrNull() } + +private fun patchOf(obj: JsonObject?): String? = + obj?.get("patch")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + +/** + * Unified diff patch produced by a write tool, or empty when none is available. Kilo strips the raw + * `diff` field from stored parts (see stripPartMetadata) but keeps `filediff.patch` (edit/write) and + * per-file `files[].patch` (apply_patch) when under the size cap, so read those first. + */ +internal fun editDiff(tool: Tool): String { + tool.metadata["diff"]?.takeIf { it.isNotBlank() }?.let { return it } + patchOf(parseJsonObject(tool.metadata["filediff"]))?.let { return it } + parseJsonArray(tool.metadata["files"])?.let { files -> + val joined = files.mapNotNull { patchOf(it.jsonObject) }.joinToString("\n") + if (joined.isNotBlank()) return joined + } + return "" +} + +/** One file touched by an apply_patch call, parsed from the tool's `files[]` metadata. */ +internal data class EditFileChange( + val path: String, + val type: String, + val additions: Int, + val deletions: Int, + val patch: String, +) + +/** Per-file changes from an apply_patch tool; empty for single-file edit/write tools (`filediff`). */ +internal fun editFiles(tool: Tool): List = + parseJsonArray(tool.metadata["files"])?.mapNotNull { element -> + val obj = element.jsonObject + val path = editFile(obj) ?: return@mapNotNull null + EditFileChange( + path = path, + type = obj["type"]?.jsonPrimitive?.contentOrNull.orEmpty(), + additions = obj["additions"]?.jsonPrimitive?.intOrNull ?: 0, + deletions = obj["deletions"]?.jsonPrimitive?.intOrNull ?: 0, + patch = patchOf(obj).orEmpty(), + ) + } ?: emptyList() + +/** + * Sectioned markdown for a multi-file patch: each file gets a labeled header line (path plus its own + * add/remove counts) followed by its own fenced diff, so the joined apply_patch diff no longer runs + * together into one indistinguishable block. The path is wrapped in inline code so characters like + * underscores are not parsed as markdown emphasis. + */ +internal fun multiFileDiffMarkdown(files: List): String = + files.filter { it.patch.isNotBlank() }.joinToString("\n\n") { file -> + buildString { + append('`').append(tail(file.path)).append('`') + append(" +").append(file.additions).append(" -").append(file.deletions) + append("\n\n") + append(patchMarkdown(file.patch)) + } + } + +/** Added/removed line counts, preferring the counts computed by the CLI, else counting patch lines. */ +internal fun diffStat(tool: Tool): Pair { + parseJsonObject(tool.metadata["filediff"])?.let { fd -> + val add = fd["additions"]?.jsonPrimitive?.intOrNull + val del = fd["deletions"]?.jsonPrimitive?.intOrNull + if (add != null || del != null) return (add ?: 0) to (del ?: 0) + } + parseJsonArray(tool.metadata["files"])?.let { files -> + var add = 0 + var del = 0 + var found = false + files.forEach { + it.jsonObject["additions"]?.jsonPrimitive?.intOrNull?.let { v -> add += v; found = true } + it.jsonObject["deletions"]?.jsonPrimitive?.intOrNull?.let { v -> del += v; found = true } + } + if (found) return add to del + } + val patch = editDiff(tool) + if (patch.isBlank()) return 0 to 0 + var added = 0 + var removed = 0 + for (line in patch.lineSequence()) { + when { + line.startsWith("+++") || line.startsWith("---") -> Unit + line.startsWith("+") -> added++ + line.startsWith("-") -> removed++ + } + } + return added to removed +} + +/** Display-only diff body without VCS/file metadata headers (Index, diff --git, ---, +++, etc.). */ +internal fun pureDiff(diff: String): String = diff.lineSequence() + .filterNot(::diffMeta) + .joinToString("\n") + .trim('\n') + +private fun diffMeta(line: String): Boolean = line.startsWith("Index:") || + line.startsWith("====") || + line.startsWith("diff --git ") || + line.startsWith("@@") || + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("new file mode ") || + line.startsWith("deleted file mode ") || + line.startsWith("old mode ") || + line.startsWith("new mode ") || + line.startsWith("similarity index ") || + line.startsWith("dissimilarity index ") || + line.startsWith("rename from ") || + line.startsWith("rename to ") || + line.startsWith("copy from ") || + line.startsWith("copy to ") + +/** Wraps a unified patch in a fenced `patch` block so the markdown code editor highlights it. */ +internal fun patchMarkdown(diff: String): String = buildString { + // Fall back to the raw patch when stripping metadata leaves nothing (e.g. a pure rename or + // mode-only change with no +/-/context lines) so we never render an empty fenced block. + val body = pureDiff(diff).ifBlank { diff.trim('\n') } + val fence = fence(body) + append(fence).append("patch-pure\n") + append(body) + if (!body.endsWith('\n')) append('\n') + append(fence) +} + +internal fun fence(text: String): String { + val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0 + return "`".repeat(maxOf(3, size + 1)) +} + internal fun tail(path: String): String { val value = path.trimEnd('/', '\\') val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdDiffHighlight.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdDiffHighlight.kt new file mode 100644 index 00000000000..f7d2c3a3d0e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdDiffHighlight.kt @@ -0,0 +1,90 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.diff.DiffColors +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.HighlighterLayer +import com.intellij.openapi.editor.markup.HighlighterTargetArea + +/** + * Overlays unified-diff coloring on a plain-text code editor: added lines get the theme's diff + * "inserted" background, removed lines the "deleted" background, hunk headers a keyword color, and + * file/index headers a dimmed comment color. Colors come from the active scheme via [DiffColors] + * and [DefaultLanguageHighlighterColors], so the result tracks the IDE theme like the diff viewer. + */ +internal object MdDiffHighlight { + data class Span(val key: TextAttributesKey, val area: HighlighterTargetArea) + data class Display(val text: String, val spans: List) + data class Range(val start: Int, val end: Int, val span: Span) + + fun apply(editor: EditorEx, text: String) { + editor.markupModel.removeAllHighlighters() + val doc = editor.document + val size = doc.textLength + for (n in 0 until doc.lineCount) { + val start = doc.getLineStartOffset(n).coerceAtMost(size) + val end = doc.getLineEndOffset(n).coerceAtMost(size) + if (start >= end) continue + val span = classify(doc.charsSequence.subSequence(start, end).toString()) ?: continue + editor.markupModel.addRangeHighlighter(span.key, start, end, HighlighterLayer.SYNTAX + 1, span.area) + } + } + + fun applyPure(editor: EditorEx, text: String) { + editor.markupModel.removeAllHighlighters() + val doc = editor.document + for (range in display(text).spans) { + val start = range.start.coerceAtMost(doc.textLength) + val end = range.end.coerceAtMost(doc.textLength) + if (start >= end) continue + editor.markupModel.addRangeHighlighter(range.span.key, start, end, HighlighterLayer.SYNTAX + 1, range.span.area) + } + } + + fun display(text: String): Display { + val out = StringBuilder() + val ranges = mutableListOf() + text.lineSequence().forEachIndexed { i, line -> + if (i > 0) out.append('\n') + val span = classify(line) + val body = when { + line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") -> line.drop(1) + else -> line + } + val start = out.length + out.append(body) + if (span != null) ranges.add(Range(start, out.length, span)) + } + return Display(out.toString(), ranges) + } + + private fun classify(line: String): Span? = when { + fileHeader(line) || meta(line) -> comment + line.startsWith("@@") -> hunk + line.startsWith("+") -> inserted + line.startsWith("-") -> deleted + else -> null + } + + // Unified-diff file headers are the marker followed by a space (or the bare marker), e.g. "+++ b/f". + // Guarding on that shape keeps content lines like "++x;" (an inserted "+x;") from being dimmed. + private fun fileHeader(line: String): Boolean = + (line.startsWith("+++") || line.startsWith("---")) && + (line.length == 3 || line[3] == ' ' || line[3] == '\t') + + private fun meta(line: String): Boolean = line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("Index:") || + line.startsWith("===") || + line.startsWith("new file") || + line.startsWith("deleted file") || + line.startsWith("rename ") || + line.startsWith("similarity ") || + line.startsWith("\\ No newline") + + private val inserted = Span(DiffColors.DIFF_INSERTED, HighlighterTargetArea.LINES_IN_RANGE) + private val deleted = Span(DiffColors.DIFF_DELETED, HighlighterTargetArea.LINES_IN_RANGE) + private val hunk = Span(DefaultLanguageHighlighterColors.KEYWORD, HighlighterTargetArea.EXACT_RANGE) + private val comment = Span(DefaultLanguageHighlighterColors.LINE_COMMENT, HighlighterTargetArea.EXACT_RANGE) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt index e40e6848f57..3be503139e0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdLanguage.kt @@ -6,7 +6,7 @@ import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType internal sealed class Kind { - data class Source(val file: FileType) : Kind() + data class Source(val file: FileType, val highlight: Highlight = Highlight.None) : Kind() data class Terminal(val stream: Stream, val mode: Mode) : Kind() } @@ -14,6 +14,9 @@ internal enum class Stream { Stdout, Stderr } internal enum class Mode { Ansi, Shell, Command } +/** Extra overlay highlighting applied on top of a source code block. */ +internal enum class Highlight { None, Diff, DiffPure } + internal object MdLanguage { /** Internal terminal fence tags produced by ShellToolView shell transcript markdown. */ private val terms = mapOf( @@ -58,11 +61,16 @@ internal object MdLanguage { "terraform" to "tf", ) + private val diffs = setOf("diff", "patch", "udiff") + private val pure = setOf("diff-pure", "patch-pure") + fun kind(lang: String?): Kind { val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty() terms[key]?.let { return it } if (key == "shell script") return Kind.Source(type("sh")) val single = key.substringBefore(' ') + if (key in pure || single in pure) return Kind.Source(PlainTextFileType.INSTANCE, Highlight.DiffPure) + if (key in diffs || single in diffs) return Kind.Source(PlainTextFileType.INSTANCE, Highlight.Diff) terms[single]?.let { return it } files[key]?.let { return Kind.Source(type(it)) } files[single]?.let { return Kind.Source(type(it)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index 0ee76364ae6..3453cad9153 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -314,7 +314,7 @@ internal open class MdViewHybrid( is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable) is Desc.Table -> TableView(desc, tableBlock(desc.body, disposable), disposable) is Desc.Code -> when (val kind = desc.kind) { - is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable) + is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind, disposable), disposable) is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable) } } @@ -331,27 +331,38 @@ internal open class MdViewHybrid( customStyleSheetProvider { sheet() } }, ), UiDataProvider { + // A stationary pointer over scrolling content must keep this pane's hovered link and + // cursor fresh, so we replay a synthetic mouse move whenever the enclosing viewport + // scrolls. Only the pane under the pointer subscribes — otherwise every prose block in a + // large transcript would run a native pointer query + event dispatch on every scroll tick. private var viewport: JViewport? = null + private var listening = false private val scroll = ChangeListener { hover() } + private val pointer = object : java.awt.event.MouseAdapter() { + override fun mouseEntered(e: MouseEvent) = listen(true) + override fun mouseExited(e: MouseEvent) = listen(false) + } private val hierarchy = java.awt.event.HierarchyListener { event -> - if (event.changeFlags and HierarchyEvent.PARENT_CHANGED.toLong() != 0L) attach() + if (event.changeFlags and HierarchyEvent.PARENT_CHANGED.toLong() != 0L) retarget() } init { + addMouseListener(pointer) addHierarchyListener(hierarchy) Disposer.register(disposable) { - viewport?.removeChangeListener(scroll) + listen(false) + removeMouseListener(pointer) removeHierarchyListener(hierarchy) } } override fun addNotify() { super.addNotify() - attach() + retarget() } override fun removeNotify() { - viewport?.removeChangeListener(scroll) + listen(false) viewport = null super.removeNotify() } @@ -360,12 +371,20 @@ internal open class MdViewHybrid( selection?.provideCopy(sink) { document.getText(0, document.length).trim() } } - private fun attach() { + // Follow the enclosing viewport as this pane is reparented, keeping any live subscription. + private fun retarget() { val next = SwingUtilities.getAncestorOfClass(JViewport::class.java, this) as? JViewport if (viewport === next) return - viewport?.removeChangeListener(scroll) + if (listening) viewport?.removeChangeListener(scroll) viewport = next - next?.addChangeListener(scroll) + if (listening) viewport?.addChangeListener(scroll) + } + + // Track viewport scrolls only while the pointer is over this pane. + private fun listen(on: Boolean) { + if (listening == on) return + listening = on + if (on) viewport?.addChangeListener(scroll) else viewport?.removeChangeListener(scroll) } private fun hover() { @@ -424,20 +443,20 @@ internal open class MdViewHybrid( return pane } - private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane { + private fun codeBlock(text: String, kind: Kind.Source, disposable: Disposable): JBScrollPane { val opts = opts() - val value = text.trimEnd('\n') + val value = sourceText(text, kind) val field = runCatching { - codeField(file, opts, text, false, disposable) + codeField(kind.file, opts, value, false, disposable) }.getOrElse { err -> LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err) if (code.opts.editorOnly) runCatching { - codeField(PlainTextFileType.INSTANCE, opts, text, false, disposable) + codeField(PlainTextFileType.INSTANCE, opts, value, false, disposable) }.getOrElse { fallback -> LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback) throw fallback } else { - textArea(text, opts, disposable) + textArea(value, opts, disposable) } } sizeCodeField(field, value) @@ -451,6 +470,12 @@ internal open class MdViewHybrid( return pane } + private fun sourceText(text: String, kind: Kind.Source): String { + val value = text.trimEnd('\n') + if (kind.highlight == Highlight.DiffPure) return MdDiffHighlight.display(value).text + return value + } + private fun terminalBlock(text: String, kind: Kind.Terminal, disposable: Disposable): JBScrollPane { val opts = opts() val term = MdTerminal.decode(text, kind.stream) @@ -823,12 +848,18 @@ internal open class MdViewHybrid( private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) : View(desc, pane, disposable) { + init { + overlay() + } + override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind override fun update(desc: Desc) { if (this.desc == desc) return this.desc = desc - val value = (desc as Desc.Code).text.trimEnd('\n') + val item = desc as Desc.Code + val kind = item.kind as? Kind.Source + val value = if (kind == null) item.text.trimEnd('\n') else sourceText(item.text, kind) val view = pane.viewport.view when (view) { is CodeField -> view.text = value @@ -838,6 +869,20 @@ internal open class MdViewHybrid( sizeCodeField(view, value) sizeCodePane(pane, view) } + overlay() + } + + /** Applies unified-diff coloring on top of a `diff`/`patch` block; a no-op otherwise. */ + private fun overlay() { + val kind = (desc as Desc.Code).kind + if (kind !is Kind.Source || kind.highlight == Highlight.None) return + val field = pane.viewport.view as? CodeField ?: return + val editor = field.getEditor(true) ?: return + if (kind.highlight == Highlight.DiffPure) { + MdDiffHighlight.applyPure(editor, (desc as Desc.Code).text.trimEnd('\n')) + return + } + MdDiffHighlight.apply(editor, field.text) } override fun grow(delta: String) { @@ -861,6 +906,7 @@ internal open class MdViewHybrid( sizeCodeField(view, text) sizeCodePane(pane, view) } + overlay() } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index e5e67c902ca..57788370bd6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -121,6 +121,11 @@ defaultValue="180000" restartRequired="false" overrides="false"/> + 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 f92997928b3..2557da487ff 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -126,6 +126,9 @@ session.part.tool.error=Error session.part.tool.agent={0} Agent session.part.tool.pending=Pending session.part.tool.read=Read +session.part.tool.edit=Edit +session.part.tool.edit.files={0} files +session.part.tool.patch=Patch session.part.tool.glob=Glob session.part.tool.search=Search session.part.tool.running=Running diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 66e48f8ad65..f439e041589 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -57,6 +57,7 @@ session.part.tool.copy=نسخ session.part.tool.error=خطأ session.part.tool.pending=معلق session.part.tool.read=قراءة +session.part.tool.edit=تحرير session.part.tool.running=قيد التشغيل session.part.tool.shell=Shell session.part.tool.truncated=المخرجات مختصرة في المعاينة المسبقة. المخرجات الكاملة لا تزال في بيانات الجلسة. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 120231ea0e5..3c3fe1f71a9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopiraj session.part.tool.error=Greška session.part.tool.pending=Na čekanju session.part.tool.read=Čita +session.part.tool.edit=Uredi session.part.tool.running=Pokrenuto session.part.tool.shell=Shell session.part.tool.truncated=Izlaz skraćen u pregledu. Potpuni izlaz ostaje u podacima sesije. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 46a706a7280..8d0d26a7223 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopiér session.part.tool.error=Fejl session.part.tool.pending=Afventer session.part.tool.read=Læs +session.part.tool.edit=Rediger session.part.tool.running=Kører session.part.tool.shell=Shell session.part.tool.truncated=Output afkortet i forhåndsvisning. Fuldt output forbliver i sessionsdata. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 8b53447d4cd..181ac9f1654 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopieren session.part.tool.error=Fehler session.part.tool.pending=Ausstehend session.part.tool.read=Lesen +session.part.tool.edit=Bearbeiten session.part.tool.running=Läuft session.part.tool.shell=Shell session.part.tool.truncated=Ausgabe in der Vorschau gekürzt. Vollständige Ausgabe verbleibt in den Sitzungsdaten. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index ef2d8af3a1f..fb67a0de551 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Copiar session.part.tool.error=Error session.part.tool.pending=Pendiente session.part.tool.read=Leer +session.part.tool.edit=Editar session.part.tool.running=Ejecutando session.part.tool.shell=Shell session.part.tool.truncated=Salida truncada en la vista previa. La salida completa permanece en los datos de la sesión. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index ff1daacb0b0..3848ae7ee98 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Copier session.part.tool.error=Erreur session.part.tool.pending=En attente session.part.tool.read=Lire +session.part.tool.edit=Modifier session.part.tool.running=En cours session.part.tool.shell=Shell session.part.tool.truncated=Sortie tronquée dans l'aperçu. La sortie complète reste dans les données de session. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index b4963978f6c..8c9e3d12c06 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -57,6 +57,7 @@ session.part.tool.copy=コピー session.part.tool.error=エラー session.part.tool.pending=保留中 session.part.tool.read=読み取り +session.part.tool.edit=編集 session.part.tool.running=実行中 session.part.tool.shell=シェル session.part.tool.truncated=プレビューでは出力が切り詰められています。完全な出力はセッションデータに残っています。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 46d864cb643..b463a9b1951 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -57,6 +57,7 @@ session.part.tool.copy=복사 session.part.tool.error=오류 session.part.tool.pending=대기 중 session.part.tool.read=읽기 +session.part.tool.edit=편집 session.part.tool.running=실행 중 session.part.tool.shell=셸 session.part.tool.truncated=미리보기에서 출력이 잘렸습니다. 전체 출력은 세션 데이터에 남아 있습니다. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index fb4fee9a8a9..b29e1b9dc21 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopiëren session.part.tool.error=Fout session.part.tool.pending=In afwachting session.part.tool.read=Lezen +session.part.tool.edit=Bewerken session.part.tool.running=Actief session.part.tool.shell=Shell session.part.tool.truncated=Uitvoer ingekort in voorvertoning. Volledige uitvoer blijft beschikbaar in sessiegegevens. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index 45b9c723c5b..c577c420520 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopier session.part.tool.error=Feil session.part.tool.pending=Venter session.part.tool.read=Les +session.part.tool.edit=Rediger session.part.tool.running=Kjører session.part.tool.shell=Shell session.part.tool.truncated=Utdata avkortet i forhåndsvisning. Fullstendig utdata finnes fortsatt i øktdata. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 842404753ec..d57e58ac718 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopiuj session.part.tool.error=Błąd session.part.tool.pending=Oczekuje session.part.tool.read=Odczyt +session.part.tool.edit=Edycja session.part.tool.running=Uruchomione session.part.tool.shell=Powłoka session.part.tool.truncated=Wyjście skrócone w podglądzie. Pełne wyjście pozostaje w danych sesji. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 6b438cc43db..6655ce84b0f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Copiar session.part.tool.error=Erro session.part.tool.pending=Pendente session.part.tool.read=Ler +session.part.tool.edit=Editar session.part.tool.running=Executando session.part.tool.shell=Shell session.part.tool.truncated=Saída truncada na pré-visualização. A saída completa permanece nos dados da sessão. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index bbf5e28c6a7..c1eaee006ad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Копировать session.part.tool.error=Ошибка session.part.tool.pending=Ожидание session.part.tool.read=Чтение +session.part.tool.edit=Редактирование session.part.tool.running=Выполняется session.part.tool.shell=Shell session.part.tool.truncated=Вывод усечён в предпросмотре. Полный вывод сохраняется в данных сессии. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index d95ddf999b1..89e6d9a8df9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -57,6 +57,7 @@ session.part.tool.copy=คัดลอก session.part.tool.error=ข้อผิดพลาด session.part.tool.pending=รอดำเนินการ session.part.tool.read=อ่าน +session.part.tool.edit=แก้ไข session.part.tool.running=กำลังทำงาน session.part.tool.shell=Shell session.part.tool.truncated=ผลลัพธ์ถูกตัดทอนในส่วนตัวอย่าง ผลลัพธ์ทั้งหมดยังคงอยู่ในข้อมูลเซสชัน diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 498e1d4d6b5..d6ee1e84d97 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Kopyala session.part.tool.error=Hata session.part.tool.pending=Bekliyor session.part.tool.read=Oku +session.part.tool.edit=Düzenle session.part.tool.running=Çalışıyor session.part.tool.shell=Kabuk session.part.tool.truncated=Önizlemede çıktı kısaltıldı. Tam çıktı oturum verilerinde kalıyor. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 0284472e777..f385d236f4e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -57,6 +57,7 @@ session.part.tool.copy=Копіювати session.part.tool.error=Помилка session.part.tool.pending=Очікується session.part.tool.read=Читання +session.part.tool.edit=Редагування session.part.tool.running=Виконується session.part.tool.shell=Shell session.part.tool.truncated=Вивід у попередньому перегляді усічено. Повний вивід зберігається в даних сесії. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 7366a4199b9..3f4b3fbc3db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -57,6 +57,7 @@ session.part.tool.copy=复制 session.part.tool.error=错误 session.part.tool.pending=待处理 session.part.tool.read=读取 +session.part.tool.edit=编辑 session.part.tool.running=运行中 session.part.tool.shell=Shell session.part.tool.truncated=预览中的输出已截断。完整输出仍保留在会话数据中。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 40d6eb7a5ec..dd2898d09a8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -57,6 +57,7 @@ session.part.tool.copy=複製 session.part.tool.error=錯誤 session.part.tool.pending=待處理 session.part.tool.read=讀取 +session.part.tool.edit=編輯 session.part.tool.running=執行中 session.part.tool.shell=Shell session.part.tool.truncated=預覽中的輸出已截斷。完整輸出仍保留在工作階段資料中。 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt index 0a5eaf824f0..ed069137883 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt @@ -7,6 +7,7 @@ import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import java.awt.Dimension import java.awt.Insets +import javax.swing.JPanel import javax.swing.JLabel /** @@ -281,6 +282,66 @@ class SessionLayoutTest : BasePlatformTestCase() { assertEquals(20 + JBUI.scale(8), c2.y) } + fun `test valid child reuses cached preferred height`() { + val p = panel(width = 300) + val child = probe(height = 20) + p.add(child) + p.doLayout() + child.markValid() + val count = child.count + + p.doLayout() + + assertEquals(count, child.count) + assertEquals(20, child.height) + } + + fun `test invalid child is measured again`() { + val p = panel(width = 300) + val child = probe(height = 20) + p.add(child) + p.doLayout() + child.markValid() + val count = child.count + + child.invalidate() + p.doLayout() + + assertEquals(count + 1, child.count) + } + + fun `test width change forces cached child remeasure`() { + val p = panel(width = 300) + val child = probe(height = 20) + p.add(child) + p.doLayout() + child.markValid() + val count = child.count + + p.setSize(320, 2000) + p.doLayout() + + assertEquals(count + 1, child.count) + assertEquals(320, child.width) + } + + fun `test forget re-measures a valid child`() { + val p = panel(width = 300) + val child = probe(height = 20) + p.add(child) + p.doLayout() + child.markValid() + val count = child.count + + // A settled turn is its own validate root, so it can be re-validated independently and its + // isValid flag flips back to true even after its content (and height) changed. forget() + // drops the stale cached height so the next layout pass re-measures the child. + (p.layout as SessionLayout).forget(child) + p.doLayout() + + assertEquals(count + 1, child.count) + } + // ---- helpers ------ /** A fixed-height JLabel. The width is reported as 0 until layout sets it. */ @@ -293,4 +354,25 @@ class SessionLayoutTest : BasePlatformTestCase() { override fun getPreferredSize(): Dimension = Dimension(0, height) } + + private fun probe(height: Int) = object : JPanel() { + var count = 0 + private var valid = false + + override fun isValid() = valid + + override fun invalidate() { + valid = false + super.invalidate() + } + + fun markValid() { + valid = true + } + + override fun getPreferredSize(): Dimension { + count++ + return Dimension(0, height) + } + } } 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 3c12ebdda27..d5e72cfc081 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 @@ -39,6 +39,8 @@ import ai.kilocode.rpc.dto.TodoDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.registry.Registry +import com.intellij.openapi.util.registry.RegistryKeyDescriptor import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.ActionLink import com.intellij.ui.components.JBLabel @@ -53,7 +55,9 @@ import java.awt.Point import java.awt.event.MouseEvent import java.awt.image.BufferedImage import javax.swing.JButton +import javax.swing.JComponent import javax.swing.JPanel +import javax.swing.RepaintManager import javax.swing.SwingUtilities import javax.swing.border.Border @@ -401,6 +405,151 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals("hello world", tv.markdown()) } + fun `test empty ContentDelta does not refresh panel`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "hello")) + val mv = panel.findMessage("a1")!! + val tv = mv.part("p1") as TextView + val repaint = TrackingRepaintManager(setOf(panel, mv, tv)) + val old = RepaintManager.currentManager(panel) + + try { + RepaintManager.setCurrentManager(repaint) + + model.appendDelta("a1", "p1", "") + + assertEquals("hello", tv.markdown()) + assertTrue(repaint.dirty.isEmpty()) + assertTrue(repaint.invalid.isEmpty()) + } finally { + RepaintManager.setCurrentManager(old) + } + } + + fun `test identical ContentUpdated does not refresh panel`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "hello")) + val mv = panel.findMessage("a1")!! + val tv = mv.part("p1") as TextView + val comp = tv.md.component + val repaint = TrackingRepaintManager(setOf(panel, mv, tv)) + val old = RepaintManager.currentManager(panel) + + try { + RepaintManager.setCurrentManager(repaint) + + model.updateContent("a1", part("p1", "a1", "text", text = "hello")) + + assertSame(tv, mv.part("p1")) + assertSame(comp, tv.md.component) + assertTrue(repaint.dirty.isEmpty()) + assertTrue(repaint.invalid.isEmpty()) + } finally { + RepaintManager.setCurrentManager(old) + } + } + + // ------ settled turns / validate roots (B) ------ + + fun `test turns are validate roots when idle`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + + assertTrue(panel.findTurn("u1")!!.isValidateRoot()) + assertTrue(panel.findTurn("u2")!!.isValidateRoot()) + } + + fun `test streaming turn is not a validate root while busy`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + + model.setState(SessionState.Busy("thinking")) + + assertTrue("prior turn stays a validate root", panel.findTurn("u1")!!.isValidateRoot()) + assertFalse("streaming turn must not be a validate root", panel.findTurn("u2")!!.isValidateRoot()) + } + + fun `test turns settle again when idle`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("u2", "user")) + model.setState(SessionState.Busy("thinking")) + + model.setState(SessionState.Idle) + + assertTrue(panel.findTurn("u1")!!.isValidateRoot()) + assertTrue(panel.findTurn("u2")!!.isValidateRoot()) + } + + fun `test turn added while busy becomes the active non-root turn`() { + model.upsertMessage(msg("u1", "user")) + model.setState(SessionState.Busy("thinking")) + assertFalse(panel.findTurn("u1")!!.isValidateRoot()) + + model.upsertMessage(msg("u2", "user")) + + assertTrue("previous turn settles once a newer turn is active", panel.findTurn("u1")!!.isValidateRoot()) + assertFalse("newest turn is the active streaming turn", panel.findTurn("u2")!!.isValidateRoot()) + } + + fun `test validate roots flag disables turn isolation`() { + disableValidateRoots() + model.upsertMessage(msg("u1", "user")) + + assertFalse(panel.findTurn("u1")!!.isValidateRoot()) + } + + fun `test settled turns still follow panel width top down`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "answer")) + val turn = panel.findTurn("a1")!! + assertTrue("idle turn is a validate root", turn.isValidateRoot()) + + panel.setSize(600, 2000) + layout(panel) + val wide = turn.width + + panel.setSize(500, 2000) + layout(panel) + + assertTrue("validate-root turns must still relayout top-down", turn.width < wide) + assertTrue(turn.isValidateRoot()) + } + + // ------ streaming stress / teardown ------ + + fun `test many streamed turns stay bounded and fully tear down`() { + val empty = count(panel) + + repeat(40) { i -> + model.upsertMessage(msg("u$i", "user")) + model.updateContent("u$i", part("up$i", "u$i", "text", text = "q$i")) + model.upsertMessage(msg("a$i", "assistant")) + model.updateContent("a$i", part("ap$i", "a$i", "text", text = "```kotlin\nval x = $i\n```")) + repeat(20) { j -> model.appendDelta("a$i", "ap$i", " tok$j") } + } + assertEquals(40, panel.turnCount()) + + // Retained instances stay identical while streaming into an earlier message, + // and streaming deltas must not grow the component tree. + val tv = panel.findMessage("a0")!!.part("ap0") as TextView + val comp = tv.md.component + val count = count(panel) + repeat(50) { model.appendDelta("a0", "ap0", " x$it") } + + assertSame(tv, panel.findMessage("a0")!!.part("ap0")) + assertSame(comp, tv.md.component) + assertEquals(count, count(panel)) + + model.clear() + + assertEquals(0, panel.turnCount()) + assertTrue("transcript turns must be removed on clear", panel.components.none { it is TurnView }) + assertEquals("clear must return the transcript to its empty component tree", empty, count(panel)) + } + fun `test ContentDelta preserves TextView and markdown component`() { model.upsertMessage(msg("a1", "assistant")) model.updateContent("a1", part("p1", "a1", "text", text = "first\n\nsecond")) @@ -1152,6 +1301,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { for (child in root.components) if (child is Container) layout(child) } + /** The plugin's `` extensions are not loaded in tests, so contribute the key here. */ + private fun disableValidateRoots() { + val key = "kilo.session.validateRoots" + Registry.mutateContributedKeys { + it + (key to RegistryKeyDescriptor(key, "test", "true", false, false, null, null)) + } + Disposer.register(testRootDisposable) { + Registry.mutateContributedKeys { it - key } + } + Registry.get(key).setValue(false, testRootDisposable) + } + private fun promptBox(root: MessageView): Component { return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView } } @@ -1175,4 +1336,19 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { .joinToString(" ") } } + + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { + val dirty = mutableListOf() + val invalid = mutableListOf() + + override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) { + if (c in watched) dirty.add(c) + super.addDirtyRegion(c, x, y, w, h) + } + + override fun addInvalidComponent(invalidComponent: JComponent) { + if (invalidComponent in watched) invalid.add(invalidComponent) + super.addInvalidComponent(invalidComponent) + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt new file mode 100644 index 00000000000..4cfaf8d2c61 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/EditToolViewTest.kt @@ -0,0 +1,476 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.EditToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.ui.DiffStatBadge +import com.intellij.openapi.diff.DiffColors +import com.intellij.openapi.editor.EditorFactory +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 com.intellij.util.ui.UIUtil +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.awt.Component +import java.awt.Container +import java.awt.event.MouseEvent + +@Suppress("UnstableApiUsage") +class EditToolViewTest : BasePlatformTestCase() { + + private val views = mutableListOf() + + override fun tearDown() { + views.forEach { Disposer.dispose(it) } + views.clear() + super.tearDown() + } + + fun `test edit tool shows Edit title and clickable file link`() { + val opened = mutableListOf() + val view = track(EditToolView(tool(), openFile = { href, _ -> opened.add(href) })) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Edit")) + assertTrue(view.linkVisible()) + assertEquals("App.kt", view.linkLabel()) + assertEquals("/repo/src/App.kt", view.linkHref()) + assertEquals("/repo/src/App.kt", view.linkTooltip()) + assertTrue(view.labelText().contains("App.kt")) + + view.openLink() + + assertEquals(listOf("/repo/src/App.kt"), opened) + } + + fun `test edit link uses metadata path when input is only filename`() { + val opened = mutableListOf() + val path = "backend/src/com/kirillk/watcher/dao/GameApi.java" + val view = track(EditToolView(tool().also { + it.title = "GameApi.java" + it.input = mapOf("filePath" to "GameApi.java") + it.metadata = mapOf("filediff" to fileDiff(1, 0, PATCH, path)) + }, openFile = { href, _ -> opened.add(href) })) + + assertEquals("GameApi.java", view.linkLabel()) + assertEquals(path, view.linkHref()) + + view.openLink() + + assertEquals(listOf(path), opened) + } + + fun `test changes tag shows additions and deletions`() { + val view = track(EditToolView(tool())) + + assertTrue(view.badgeVisible()) + assertEquals(2 to 1, view.diffStat()) + } + + fun `test changes tag hidden without diff`() { + val view = track(EditToolView(tool().also { it.metadata = emptyMap() })) + + assertFalse(view.badgeVisible()) + assertEquals(0 to 0, view.diffStat()) + } + + fun `test multi file apply_patch shows file count tag and aggregated changes`() { + val view = track(EditToolView(tool().also { + it.input = emptyMap() + it.metadata = mapOf("files" to filesMeta( + FileChange("src/A.kt", 2, 0, ADD_HUNK), + FileChange("src/B.kt", 1, 1, UPDATE_HUNK), + )) + })) + + assertTrue(view.labelText().contains("Patch")) + assertFalse(view.labelText().contains("Edit")) + assertTrue(view.filesTagVisible()) + assertTrue(view.filesTagText()!!.contains("2 files")) + assertFalse(view.linkVisible()) + assertTrue(view.badgeVisible()) + assertEquals(3 to 1, view.diffStat()) + } + + fun `test multi file patch body renders a link and diff per file`() { + val opened = mutableListOf() + val view = track(EditToolView(tool().also { + it.input = emptyMap() + it.metadata = mapOf("files" to filesMeta( + FileChange("src/A.kt", 2, 0, ADD_HUNK), + FileChange("pkg/B.kt", 1, 1, UPDATE_HUNK), + )) + }, openFile = { href, _ -> opened.add(href) })) + + view.toggle() + + assertTrue(view.isExpanded()) + assertEquals(2, view.codeEditors().size) + + val fileLinks = labels(view).filter { it.text?.contains("") == true } + assertTrue(fileLinks.any { it.text!!.contains("A.kt") && !it.text!!.contains("src/") }) + assertTrue(fileLinks.any { it.text!!.contains("B.kt") && !it.text!!.contains("pkg/") }) + assertTrue(fileLinks.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" }) + assertTrue(fileLinks.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" }) + + // The per-file header renders one changes badge per file (plus the aggregate header badge). + assertEquals(3, badges(view).size) + + click(fileLinks.first { it.text!!.contains("A.kt") }, 1) + assertEquals(listOf("src/A.kt"), opened) + } + + fun `test single file apply_patch keeps link and hides count tag`() { + val view = track(EditToolView(tool().also { + it.input = emptyMap() + it.title = "src/Only.kt" + it.metadata = mapOf("files" to filesMeta(FileChange("src/Only.kt", 1, 1, UPDATE_HUNK))) + })) + + assertFalse(view.filesTagVisible()) + assertTrue(view.linkVisible()) + assertEquals(1 to 1, view.diffStat()) + assertFalse(view.markdown().contains("src/Only.kt")) + assertEquals(1, Regex("```patch-pure").findAll(view.markdown()).count()) + } + + fun `test edit body renders unified diff and expands`() { + val view = track(EditToolView(tool())) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertTrue(view.markdown().contains("```patch-pure")) + assertTrue(view.markdown().contains("+new1")) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertTrue(view.bodyCreated()) + assertTrue(view.codeEditors().single().text.contains("new1")) + assertFalse(view.codeEditors().single().text.contains("+new1")) + assertFalse(view.codeEditors().single().text.contains("-old")) + } + + fun `test edit body strips patch metadata headers`() { + // Relative-path headers so the `--- `/`+++ ` file-header assertions below actually exercise + // stripping: the header text (`--- src/App.kt`) shares its prefix with nothing in the body. + val patch = """ + Index: src/App.kt + =================================================================== + --- src/App.kt + +++ src/App.kt + @@ -1,2 +1,2 @@ + keep + -old + +new + """.trimIndent() + val view = track(EditToolView(tool().also { it.metadata = mapOf("filediff" to fileDiff(1, 1, patch)) })) + + assertFalse(view.markdown().contains("@@ -1,2 +1,2 @@")) + assertTrue(view.markdown().contains("-old")) + assertTrue(view.markdown().contains("+new")) + assertFalse(view.markdown().contains("Index:")) + assertFalse(view.markdown().contains("--- src/App.kt")) + assertFalse(view.markdown().contains("+++ src/App.kt")) + assertFalse(view.markdown().contains("====")) + + view.toggle() + + assertTrue(view.codeEditors().single().text.contains("old")) + assertTrue(view.codeEditors().single().text.contains("new")) + assertFalse(view.codeEditors().single().text.contains("-old")) + assertFalse(view.codeEditors().single().text.contains("+new")) + } + + fun `test edit body colors added and removed diff lines`() { + val view = track(EditToolView(tool())) + view.toggle() + val editor = view.codeEditors().single().getEditor(true)!! + val chars = editor.document.charsSequence + val spans = editor.markupModel.allHighlighters.mapNotNull { h -> + val key = h.textAttributesKey ?: return@mapNotNull null + key to chars.subSequence(h.startOffset, h.endOffset).toString() + } + + assertTrue(spans.any { it.first == DiffColors.DIFF_INSERTED && it.second.startsWith("new1") }) + assertTrue(spans.any { it.first == DiffColors.DIFF_DELETED && it.second.startsWith("old") }) + } + + fun `test clicking link text opens file but empty slot toggles body`() { + val opened = mutableListOf() + val view = track(EditToolView(tool(), openFile = { href, _ -> opened.add(href) })) + val link = linkLabel(view) + val slot = link.parent + + click(slot, link.preferredSize.width + 50) + + assertTrue(opened.isEmpty()) + assertTrue(view.isExpanded()) + + click(link, 0) + + assertEquals(listOf("/repo/src/App.kt"), opened) + } + + fun `test metadata only patch falls back to raw text`() { + // A pure rename (no +/-/context lines) is entirely metadata: stripping it leaves nothing, so + // the raw patch must survive rather than render an empty fenced block. + val patch = """ + diff --git a/src/Old.kt b/src/New.kt + similarity index 100% + rename from src/Old.kt + rename to src/New.kt + """.trimIndent() + val view = track(EditToolView(tool().also { it.metadata = mapOf("filediff" to fileDiff(0, 0, patch)) })) + + assertTrue(view.markdown().contains("rename from src/Old.kt")) + assertTrue(view.markdown().contains("rename to src/New.kt")) + } + + fun `test collapsed hover popup shows diff and none when expanded`() { + val view = track(EditToolView(tool())) + + assertNotNull(view.headerPopup()) + + view.toggle() + + assertNull(view.headerPopup()) + } + + fun `test edit header popup widens to diff content`() { + val patch = """ + --- src/App.kt + +++ src/App.kt + @@ -1 +1 @@ + -old + +${"x".repeat(180)} + """.trimIndent() + val view = track(EditToolView(tool().also { + it.metadata = mapOf("filediff" to fileDiff(1, 1, patch)) + })) + val body = view.headerPopup()!!.build() + + try { + assertTrue(body.component.preferredSize.width > JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + assertTrue(body.component.preferredSize.width <= JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test edit header popup stays narrow for short diff`() { + val patch = """ + --- src/App.kt + +++ src/App.kt + @@ -1 +1 @@ + -old + +new + """.trimIndent() + val view = track(EditToolView(tool().also { + it.metadata = mapOf("filediff" to fileDiff(1, 1, patch)) + })) + val body = view.headerPopup()!!.build() + + try { + assertTrue(body.component.preferredSize.width < JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test multi file patch popup reuses patch body links`() { + val opened = mutableListOf() + val view = track(EditToolView(tool().also { + it.input = emptyMap() + it.metadata = mapOf("files" to filesMeta( + FileChange("src/A.kt", 2, 0, ADD_HUNK), + FileChange("pkg/B.kt", 1, 1, UPDATE_HUNK), + )) + }, openFile = { href, _ -> opened.add(href) })) + val body = view.headerPopup()!!.build() + + try { + val fileLinks = labels(body.component).filter { it.text?.contains("") == true } + assertTrue(fileLinks.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" }) + assertTrue(fileLinks.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" }) + + click(fileLinks.first { it.text!!.contains("A.kt") }, 1) + assertEquals(listOf("src/A.kt"), opened) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test no hover popup without diff`() { + val view = track(EditToolView(tool().also { it.metadata = emptyMap() })) + + assertNull(view.headerPopup()) + } + + fun `test view factory routes write tools to edit tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = { _, _ -> }) is EditToolView) + assertTrue(ViewFactory.create(write("write"), openFile = { _, _ -> }) is EditToolView) + assertTrue(ViewFactory.create(write("apply_patch"), openFile = { _, _ -> }) is EditToolView) + } + + fun `test canRender matches write kind tools only`() { + assertTrue(EditToolView.canRender(tool())) + assertTrue(EditToolView.canRender(write("write"))) + assertFalse(EditToolView.canRender(Tool("p2", "read", toolKind("read")))) + assertFalse(EditToolView.canRender(Tool("p3", "bash", toolKind("bash")))) + } + + fun `test shouldReplace swaps generic and edit views`() { + val edit = tool() + val other = Tool("p9", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ToolView(edit), edit)) + assertTrue(ViewFactory.shouldReplace(EditToolView(edit), other)) + assertFalse(ViewFactory.shouldReplace(EditToolView(edit), edit)) + } + + fun `test edit editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(40) { i -> + val view = EditToolView(tool().also { it.metadata = mapOf("diff" to patch(i)) }) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test multi file patch editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(20) { i -> + val view = EditToolView(tool().also { + it.input = emptyMap() + it.metadata = mapOf("files" to filesMeta( + FileChange("src/A$i.kt", 2, 0, ADD_HUNK), + FileChange("src/B$i.kt", 1, 1, UPDATE_HUNK), + )) + }) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + UIUtil.dispatchAllInvocationEvents() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun track(view: EditToolView): EditToolView { + views.add(view) + return view + } + + private fun click(component: Component, x: Int) { + component.dispatchEvent(MouseEvent(component, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, x, 1, 1, false)) + } + + private fun linkLabel(view: EditToolView): JBLabel = + labels(view).first { it.text?.contains("") == true } + + private fun labels(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) labels(child) else emptyList() + if (child is JBLabel) nested + child else nested + } + + private fun badges(root: Container): List = root.components.flatMap { child -> + val nested = if (child is Container) badges(child) else emptyList() + if (child is DiffStatBadge) nested + child else nested + } + + private fun tool() = Tool("p1", "edit", toolKind("edit")).also { + it.state = ToolExecState.COMPLETED + it.title = "src/App.kt" + it.input = mapOf("filePath" to "/repo/src/App.kt") + it.output = "Edit applied successfully." + it.metadata = mapOf("filediff" to fileDiff(2, 1, PATCH)) + } + + private fun write(name: String) = Tool("p1", name, toolKind(name)).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("filePath" to "/repo/src/App.kt") + it.metadata = mapOf("filediff" to fileDiff(2, 1, PATCH)) + } + + private fun patch(i: Int) = """ + --- src/App.kt + +++ src/App.kt + @@ -1,2 +1,2 @@ + line$i + -old$i + +new$i + """.trimIndent() + + private data class FileChange(val path: String, val additions: Int, val deletions: Int, val patch: String) + + // Mirrors how the CLI serializes metadata.files (a JsonArray of per-file changes rendered to string). + private fun filesMeta(vararg files: FileChange): String = buildJsonArray { + files.forEach { file -> + addJsonObject { + put("relativePath", file.path) + put("type", "update") + put("additions", file.additions) + put("deletions", file.deletions) + put("patch", file.patch) + } + } + }.toString() + + // Mirrors how the CLI serializes metadata.filediff (a JsonObject rendered to string). + private fun fileDiff( + additions: Int, + deletions: Int, + patch: String, + path: String = "src/App.kt", + ): String = buildJsonObject { + put("file", path) + put("additions", additions) + put("deletions", deletions) + put("patch", patch) + }.toString() + + companion object { + private val PATCH = """ + --- src/App.kt + +++ src/App.kt + @@ -1,3 +1,4 @@ + line1 + -old + +new1 + +new2 + line3 + """.trimIndent() + + private val ADD_HUNK = """ + @@ -0,0 +1,2 @@ + +alpha + +beta + """.trimIndent() + + private val UPDATE_HUNK = """ + @@ -1,2 +1,2 @@ + keep + -old + +new + """.trimIndent() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index aef4c8ded13..685059de343 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -51,6 +51,7 @@ class ReadToolViewTest : BasePlatformTestCase() { assertTrue(view.linkVisible()) assertEquals("SessionUiLayoutTest.kt", view.linkText()) assertEquals(path, view.linkHref()) + assertEquals(path, view.linkTooltip()) assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb) assertEquals(view.linkFont(), view.bodyFont()) @@ -75,6 +76,7 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.linkVisible()) assertNull(view.linkHref()) + assertNull(view.linkTooltip()) assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb) assertEquals(view.subtitleFont(), view.bodyFont()) assertTrue(view.labelText().contains(path)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index 63972f05ab0..a35c86b0ba8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -290,8 +290,8 @@ class ReasoningViewTest : BasePlatformTestCase() { val panel = scroll.viewport.view as JPanel assertEquals(1, panel.components.filterIsInstance().size) - assertTrue(body.component.preferredSize.width in 1..JBUI.scale(350)) - assertEquals(JBUI.scale(450), body.component.preferredSize.height) + assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + assertEquals(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), body.component.preferredSize.height) } finally { Disposer.dispose(body.disposable) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index 551fa3dabcc..03cd9d4d2ba 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -416,9 +416,9 @@ class ShellToolViewTest : BasePlatformTestCase() { assertTrue(field.preferredSize.height - border.top >= editor.lineHeight * lines) assertTrue(field.minimumSize.height - border.top >= editor.lineHeight * lines) assertTrue(pane.preferredSize.height >= field.preferredSize.height + pad.top + pad.bottom) - assertTrue(body.component.preferredSize.width in 1..JBUI.scale(350)) + assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)) assertTrue(body.component.preferredSize.height > 0) - assertTrue(body.component.preferredSize.height <= JBUI.scale(450)) + assertTrue(body.component.preferredSize.height <= JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT)) } finally { Disposer.dispose(body.disposable) } @@ -427,6 +427,33 @@ class ShellToolViewTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } + fun `test shell header popup widens to command content`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "echo ${"x".repeat(180)}") + })) + val body = view.headerPopup()!!.build() + + try { + assertTrue(body.component.preferredSize.width > JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH)) + assertTrue(body.component.preferredSize.width <= JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)) + } finally { + Disposer.dispose(body.disposable) + } + } + + fun `test shell header popup stays narrow for short command`() { + val view = track(ShellToolView(tool().also { + it.input = mapOf("command" to "ls") + })) + val body = view.headerPopup()!!.build() + + try { + assertTrue(body.component.preferredSize.width < JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)) + } finally { + Disposer.dispose(body.disposable) + } + } + fun `test shell header popup breaks chained operators outside quotes`() { val view = track(ShellToolView(tool().also { it.input = mapOf( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt index f2595428c43..7682e51ba59 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.toolKind +import ai.kilocode.client.session.views.tool.EditToolView import ai.kilocode.client.session.views.tool.GlobToolView import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.session.views.tool.ShellToolView @@ -11,6 +12,8 @@ import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put @Suppress("UnstableApiUsage") class ToolBodyStressTest : BasePlatformTestCase() { @@ -62,11 +65,46 @@ class ToolBodyStressTest : BasePlatformTestCase() { assertEquals(base, EditorFactory.getInstance().allEditors.size) } + fun `test expanded edit tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = EditToolView(edit(i)) + view.toggle() + view.codeEditors().forEach { it.getEditor(true) } + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + private fun tool(index: Int) = Tool("p$index", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } } + private fun edit(index: Int) = Tool("e$index", "edit", toolKind("edit")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("filePath" to "/repo/src/File$index.kt") + val patch = buildString { + append("--- src/File$index.kt\n") + append("+++ src/File$index.kt\n") + append("@@ -1,3 +1,4 @@\n") + append(" line1\n") + append("-old$index\n") + append("+new$index\n") + } + it.metadata = mapOf( + "filediff" to buildJsonObject { + put("file", "src/File$index.kt") + put("additions", 1) + put("deletions", 1) + put("patch", patch) + }.toString(), + ) + } + private fun shell(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED it.input = mapOf("command" to "log $index") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 168ed5fa509..a8f9b91f412 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -273,6 +273,8 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(iter.isValid) val rect = pane.modelToView2D(iter.startOffset)!!.bounds + // Real AWT delivers MOUSE_ENTERED before MOUSE_MOVED; the enter arms scroll tracking. + pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, rect.x + 1, rect.y + rect.height / 2, 0, false, MouseEvent.NOBUTTON)) pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, rect.x + 1, rect.y + rect.height / 2, 0, false, MouseEvent.NOBUTTON)) host.viewport.viewPosition = Point(0, 32) drainEdt() @@ -281,6 +283,24 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue(events.contains(HyperlinkEvent.EventType.EXITED)) } + fun `test prose pane tracks viewport scrolls only while hovered`() { + view.set("See [docs](https://example.com)\n\n" + (1..20).joinToString("\n") { "line $it" }) + val pane = htmls().single() + val host = JBScrollPane(view.component) + host.setSize(420, 64) + view.component.setSize(420, view.component.preferredSize.height) + host.doLayout() + view.component.doLayout() + drainEdt() + val base = host.viewport.changeListeners.size + + pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false, MouseEvent.NOBUTTON)) + assertEquals("hovered prose pane must follow viewport scrolls", base + 1, host.viewport.changeListeners.size) + + pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, -1, -1, 0, false, MouseEvent.NOBUTTON)) + assertEquals("pane must stop following scrolls once the pointer leaves", base, host.viewport.changeListeners.size) + } + fun `test file ref links include line suffix and exclude punctuation`() { view.set("See kilocode/session/prompt.ts:302, native-plan-prompt.txt:37-38.") val html = view.html() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/hybrid/MdDiffHighlightTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/hybrid/MdDiffHighlightTest.kt new file mode 100644 index 00000000000..ad8da445a14 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/hybrid/MdDiffHighlightTest.kt @@ -0,0 +1,31 @@ +package ai.kilocode.client.ui.md.hybrid + +import com.intellij.openapi.diff.DiffColors +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +class MdDiffHighlightTest : BasePlatformTestCase() { + + fun `test inserted line whose content starts with plus plus is not dimmed as a header`() { + // "++x;" is an inserted line ("+" marker + "+x;" content), not a "+++" file header. + val out = MdDiffHighlight.display("++x;") + + assertEquals(1, out.spans.size) + assertEquals(DiffColors.DIFF_INSERTED, out.spans.single().span.key) + } + + fun `test deleted line whose content starts with a dash is not dimmed as a header`() { + // "--x" is a deleted line ("-" marker + "-x" content), not a "---" file header. + val out = MdDiffHighlight.display("--x") + + assertEquals(1, out.spans.size) + assertEquals(DiffColors.DIFF_DELETED, out.spans.single().span.key) + } + + fun `test real file headers are dimmed as comments`() { + val out = MdDiffHighlight.display("--- a/File.kt\n+++ b/File.kt") + + assertEquals(2, out.spans.size) + assertTrue(out.spans.all { it.span.key == DefaultLanguageHighlighterColors.LINE_COMMENT }) + } +}