Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/jetbrains-diff-preview-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-edit-diff-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Render edit tool results with a clickable file target and a highlighted, simplified diff view.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-edit-file-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Open edit tool file links directly when multiple files share the same name.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-multi-file-patch-view.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-scroll-hover-fanout.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-session-scroll-perf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Improve chat scrolling performance in large JetBrains sessions.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-wide-preview-popovers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Size edit and shell preview popovers to their content with a wider maximum width.
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ internal class SessionScroll(

@RequiresEdt
private fun layoutScroll() {
root.validate()
component.validate()
}

@RequiresEdt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -33,8 +34,12 @@ class SessionLayout(
private val basePad: Insets = JBUI.emptyInsets(),
) : LayoutManager {

private val cache = IdentityHashMap<Component, Measured>()

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)
Expand All @@ -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
Expand All @@ -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
Comment thread
kirillk marked this conversation as resolved.
// 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)
Expand Down Expand Up @@ -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)
}

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

Expand All @@ -132,6 +135,7 @@ class SessionMessageListPanel(

is SessionModelEvent.StateChanged -> {
syncActive(event.state)
syncSettled(event.state)
syncReverted()
syncReverting(event.state)
anchorFooter()
Expand Down Expand Up @@ -222,6 +226,7 @@ class SessionMessageListPanel(
tv.syncCopyToolbars()
syncReverted()
add(tv)
syncSettled()
anchorFooter()
refresh()
}
Expand All @@ -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)
}
}

Expand All @@ -248,6 +252,7 @@ class SessionMessageListPanel(
}
tv.syncCopyToolbars()
syncReverted()
syncSettled()

refresh()
}
Expand All @@ -257,6 +262,7 @@ class SessionMessageListPanel(
for (msgId in tv.messageIds()) unregister(msgId)
remove(tv)
Disposer.dispose(tv)
syncSettled()
anchorFooter()
refresh()
}
Expand Down Expand Up @@ -285,6 +291,7 @@ class SessionMessageListPanel(
}

syncActive(model.state)
syncSettled(model.state)
syncReverted()
syncReverting(model.state)
banner?.update()
Expand Down Expand Up @@ -313,6 +320,7 @@ class SessionMessageListPanel(
revertingMessage = null
removeAll()
syncActive(model.state)
syncSettled(model.state)
syncReverting(model.state)
banner?.update()
anchorFooter()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,26 +26,48 @@ 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
add(child, BorderLayout.CENTER)
}

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