diff --git a/.changeset/fix-jetbrains-reasoning.md b/.changeset/fix-jetbrains-reasoning.md new file mode 100644 index 00000000000..22e7c73ec3e --- /dev/null +++ b/.changeset/fix-jetbrains-reasoning.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve JetBrains reasoning blocks so active reasoning opens while streaming, completed reasoning collapses automatically, empty blocks stay hidden, and adjacent reasoning renders as one block. diff --git a/.changeset/fix-jetbrains-session-scroll.md b/.changeset/fix-jetbrains-session-scroll.md new file mode 100644 index 00000000000..f707263102b --- /dev/null +++ b/.changeset/fix-jetbrains-session-scroll.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains session scrolling so mouse wheel and keyboard scrolling no longer snap back or bounce near the transcript bottom. diff --git a/.changeset/gentle-canyon.md b/.changeset/gentle-canyon.md new file mode 100644 index 00000000000..863dfb8c229 --- /dev/null +++ b/.changeset/gentle-canyon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Prevent the JetBrains session scrollbar from covering transcript content. diff --git a/.changeset/glob-jetbrains-view.md b/.changeset/glob-jetbrains-view.md new file mode 100644 index 00000000000..be879d87bbb --- /dev/null +++ b/.changeset/glob-jetbrains-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render glob search results in the JetBrains chat as collapsible tool output with separate directory and pattern rows. diff --git a/.changeset/jetbrains-session-icons.md b/.changeset/jetbrains-session-icons.md new file mode 100644 index 00000000000..b8e4c77dcb0 --- /dev/null +++ b/.changeset/jetbrains-session-icons.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use matching VS Code-style icons for JetBrains session views. diff --git a/.changeset/quiet-jetbrains-separators.md b/.changeset/quiet-jetbrains-separators.md new file mode 100644 index 00000000000..c5a60098907 --- /dev/null +++ b/.changeset/quiet-jetbrains-separators.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Hide thematic separator lines in JetBrains chat markdown while preserving surrounding prose and code blocks. diff --git a/.changeset/refine-jetbrains-card-borders.md b/.changeset/refine-jetbrains-card-borders.md new file mode 100644 index 00000000000..d2a244ee742 --- /dev/null +++ b/.changeset/refine-jetbrains-card-borders.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders. diff --git a/.changeset/relative-jetbrains-search-paths.md b/.changeset/relative-jetbrains-search-paths.md new file mode 100644 index 00000000000..a5e48794d10 --- /dev/null +++ b/.changeset/relative-jetbrains-search-paths.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Display JetBrains search tool paths relative to the current repository when possible. diff --git a/.changeset/reset-jetbrains-session-hover.md b/.changeset/reset-jetbrains-session-hover.md new file mode 100644 index 00000000000..dc588b95f16 --- /dev/null +++ b/.changeset/reset-jetbrains-session-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Reset stale hover styling when moving between JetBrains session cards and draw card outlines only while expanded. diff --git a/.changeset/search-jetbrains-view.md b/.changeset/search-jetbrains-view.md new file mode 100644 index 00000000000..f1ca3800839 --- /dev/null +++ b/.changeset/search-jetbrains-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render grep searches in the JetBrains chat with a dedicated search header that shows stacked, clipped targets. diff --git a/.changeset/stellar-wolf.md b/.changeset/stellar-wolf.md new file mode 100644 index 00000000000..5248ade0cf4 --- /dev/null +++ b/.changeset/stellar-wolf.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve mouse wheel scrolling speed in the JetBrains session view. diff --git a/.gitignore b/.gitignore index 5611c39a766..74c51f4cea8 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ tsconfig.tsbuildinfo .kilo/bun.lock .kilo/yarn.lock .kilo/node_modules +.kilo/plans/ .kilo/plans/*upstream-merge-report-*.md .kilocode/.gitignore .kilocode/package.json diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index d9a13a7427e..3bb2c6abab0 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -268,6 +268,22 @@ Tests for retained Swing components should assert: - `update(model)` changes existing labels/body text without duplicating components. - Updates while collapsed do not eagerly create lazy bodies. - No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. +- Streaming/rebuilding surfaces additionally require stress + leak tests (see below). + +### Stress and Leak Tests for Streaming UI + +Session/transcript UI that streams updates or rebuilds its component tree (markdown +views, code blocks, transcript parts, collapsible cards) must ship stress + leak tests in +addition to behavior tests. These tests must: + +- Drive many updates (hundreds of streamed deltas or `set` cycles) through the public API. +- Assert that retained component instances stay identical across updates (`assertSame`). +- Assert the component count stays bounded — no growth per update. +- Assert disposable-backed resources return to baseline after churn + clear/dispose. + For code editors, compare `EditorFactory.getInstance().allEditors.size` against a + baseline captured before the loop. + +See `MdViewHybridStressTest` for the reference pattern. ### Platform Components and Utilities diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 99641c5e599..dd97cbabc9a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -287,7 +287,18 @@ class SessionUi( dismiss = { controller.dismissLoginRequired() }, selection = selection, ) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl, selection) + messageBody = SessionMessageListPanel( + controller.model, + this, + question, + permission, + login, + ::openFile, + ::openUrl, + selection, + repo = workspace.directory, + resize = { anchor, fn -> scroll.preserve(anchor, fn) }, + ) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) 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 d320d179b09..018cea2eb0b 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 @@ -51,10 +51,10 @@ internal class SessionScroll( private var opening = false private var stable = -1 private var seq = 0 + private var pause = false private var user = false private var value = 0 private var question = false - private var restoring = false init { jump = JBLabel(ScrollButtonIcon.create()).apply { @@ -73,7 +73,6 @@ internal class SessionScroll( user = true } }) - component.viewport.addChangeListener { onViewport() } component.verticalScrollBar.addAdjustmentListener { onScroll() } root.addOverlay(jump) { _, child -> val size = child.preferredSize @@ -98,11 +97,10 @@ internal class SessionScroll( @RequiresEdt fun atBottom(): Boolean { - val bar = component.verticalScrollBar return when { component.viewport.view !== messages -> tail - bar.maximum <= bar.visibleAmount -> true - else -> bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + !tail -> false + else -> near() } } @@ -114,6 +112,7 @@ internal class SessionScroll( return } user = false + pause = false tail = true stable = -1 auto = true @@ -139,11 +138,44 @@ internal class SessionScroll( return component.viewport.view === messages && tail } + @RequiresEdt + fun preserve(anchor: JComponent, action: () -> Unit) { + if (component.viewport.view !== messages) { + action() + return + } + val pos = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val delta = pos.y - component.viewport.viewPosition.y + seq++ + stable = -1 + user = false + pause = false + auto = true + try { + action() + layoutScroll() + val next = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val y = (next.y - delta).coerceIn(0, bottom()) + component.viewport.viewPosition = Point(0, y) + bar.value = y + } finally { + auto = false + } + tail = atBottom() + syncValue() + updateJump() + if (tail) { + stable = -1 + seq++ + } + } + @RequiresEdt fun openBottom(done: () -> Unit) { opening = true stable = -1 user = false + pause = false tail = true auto = true show(messages) @@ -169,8 +201,8 @@ internal class SessionScroll( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { this.style = style - component.background = SessionUiStyle.View.transcript() - component.viewport.background = SessionUiStyle.View.transcript() + component.background = SessionUiStyle.Transcript.bgColor() + component.viewport.background = SessionUiStyle.Transcript.bgColor() syncIcon() messages.applyStyle(style) val view = component.viewport.view @@ -189,6 +221,7 @@ internal class SessionScroll( opening = false stable = -1 user = false + pause = false tail = true auto = true show(messages) @@ -272,48 +305,61 @@ internal class SessionScroll( } @RequiresEdt - private fun onViewport() { - if (restoring || auto || opening || user || tail || component.viewport.view !== messages) return - val y = value.coerceIn(0, bottom()) - if (component.viewport.viewPosition.y == y && bar.value == y) return - restoring = true - try { - component.viewport.viewPosition = Point(0, y) - bar.value = y - } finally { - restoring = false - } - updateJump() + private fun bottom(): Int { + val bar = component.verticalScrollBar + return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) } @RequiresEdt - private fun bottom(): Int { + private fun near(): Boolean { val bar = component.verticalScrollBar - return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) + return bar.maximum <= bar.visibleAmount || bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) } @RequiresEdt private fun onScroll() { + val prev = value val moved = bar.value != value + val down = bar.value > value syncValue() if (auto || opening) { updateJump() return } if (component.viewport.view === messages) { - val bottom = atBottom() + val bottom = near() if (bottom) { - tail = true + if (user && moved && !down) { + tail = false + pause = true + } else if (!tail && !user) { + if (moved) { + auto = true + try { + bar.value = prev.coerceIn(bar.minimum, bottom()) + } finally { + auto = false + } + syncValue() + } + tail = false + } else if (pause && !user) { + tail = false + } else { + tail = true + pause = false + } user = false updateJump() return } - if (tail && (!user || !moved)) { + if (tail && !user && !moved) { user = false followBottom(true) return } tail = false + pause = false user = false seq++ } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 9b6beb55e8c..63d42d0cb5e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -94,7 +94,7 @@ class ConnectionPanel( // Keep the banner solid so expanded details cover transcript content beneath it. isOpaque = true background = UiStyle.Colors.bg() - border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1, 0, 0, 0) + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width(), 0, 0, 0) left.add(toggle, BorderLayout.WEST) left.add(label, BorderLayout.CENTER) header.add(left, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index bb2ed7d8696..5a9d6d757cb 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -5,12 +5,14 @@ import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI /** * Progress footer rendered at the bottom of the session transcript while the @@ -35,6 +37,12 @@ class ProgressPanel( init { isOpaque = false isVisible = false + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + 0, + 0, + ) applyStyle(SessionEditorStyle.current()) next(JBLabel(AnimatedIcon.Default())) 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 d2506bb67b0..18c7d7410c9 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 @@ -13,9 +13,11 @@ import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView +import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Scrollable transcript panel that maps the model's turn grouping to @@ -51,13 +53,15 @@ class SessionMessageListPanel( private val openFile: (String) -> Unit, private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val repo: String? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, + SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING, ), ), Disposable, SessionEditorStyleTarget { @@ -66,13 +70,14 @@ class SessionMessageListPanel( private val msgToView = HashMap() private var style = SessionEditorStyle.current() private var hiddenTool: ToolCallRef? = null + private var hovered: PartView? = null /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) init { isOpaque = true - background = SessionUiStyle.View.transcript() + background = SessionUiStyle.Transcript.bgColor() Disposer.register(parent, this) model.addListener(parent) { event -> @@ -177,7 +182,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -223,6 +228,7 @@ class SessionMessageListPanel( } private fun rebuild() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) @@ -233,7 +239,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -249,6 +255,7 @@ class SessionMessageListPanel( } private fun clear() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) @@ -338,9 +345,26 @@ class SessionMessageListPanel( repaint() } + private fun hover(view: PartView, value: Boolean) { + if (value) { + val prev = hovered + if (prev === view) return + hovered = view + prev?.setHovered(false) + return + } + if (hovered === view) hovered = null + } + + private fun clearHover() { + val view = hovered ?: return + hovered = null + view.setHovered(false) + } + override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = SessionUiStyle.View.transcript() + background = SessionUiStyle.Transcript.bgColor() for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) @@ -350,6 +374,7 @@ class SessionMessageListPanel( } override fun dispose() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt index 4c71a933966..5c4f63dd14b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -194,7 +194,7 @@ internal class SessionAccountOverlay( @RequiresEdt private fun showPopup() { - val bg = SessionUiStyle.View.sessionViewBackground() + val bg = SessionUiStyle.AccountPopup.bgColor() val model = CollectionListModel(choices) val list = JBList(model).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION @@ -285,7 +285,7 @@ internal class SessionAccountOverlay( internal fun choiceCount() = choices.size internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 internal fun panelBackground() = panel.background - internal fun panelBorderColor() = SessionUiStyle.View.sessionViewOutline() + internal fun panelBorderColor() = SessionUiStyle.AccountPopup.outlineColor() internal fun balanceVisible() = balance.isVisible internal fun balanceIcon() = balance.icon internal fun balanceText() = balanceText diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 1ebc2c0968a..329c895e4c3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -491,10 +491,10 @@ class PromptPanel( override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { JBUI.CurrentTheme.Focus.focusColor() } else { - SessionUiStyle.View.line() + SessionUiStyle.View.Outline.brightColor() } - override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1) + override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else SessionUiStyle.View.Outline.width() override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt index 2a52484c25a..0f7fb1a14b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -157,7 +157,7 @@ class SessionSelection : Disposable { override fun clearSelection() { val pos = component.selectionStart.coerceIn(0, component.document.length) - component.select(pos, pos) + component.caretPosition = pos } override fun applyStyle(style: SessionEditorStyle) { 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 83fc0fa2006..9ffd77482dd 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 @@ -9,52 +9,58 @@ import javax.swing.border.Border /** Static style tokens owned by the chat session UI. */ object SessionUiStyle { + object Transcript { + fun bgColor(): Color = UiStyle.Colors.bg() + } + /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { - const val GAP = 4 + const val GAP = 3 const val TRANSCRIPT_PADDING = 12 + const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 - const val SCROLL_INCREMENT = 16 + const val SCROLL_INCREMENT = 48 } /** Shared tokens for individual transcript views and session views. */ object View { - const val SESSION_VIEW_GAP = 6 - const val SESSION_VIEW_VERTICAL_PADDING = 8 - const val SESSION_VIEW_HORIZONTAL_PADDING = 12 - const val SESSION_VIEW_BODY_EXTRA_HEIGHT = 16 + object Layout { + const val GAP = 5 + const val VERTICAL_PADDING = 7 + const val HORIZONTAL_PADDING = 12 + const val BODY_EXTRA_HEIGHT = 16 + } internal const val BORDER_DELTA = 80 internal const val HOVER_BORDER_ALPHA = 0.18f internal const val HOVER_FILL_ALPHA = 0.10f - /** Creates a visible separator against editor-derived transcript surfaces. */ - fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) } + object Surface { + fun bgColor(): Color = UiStyle.Colors.editorBackground() - fun transcript(): Color = UiStyle.Colors.bg() + fun headerBgColor(): Color = UiStyle.Colors.editorBackground() - fun sessionViewBackground(): Color = UiStyle.Colors.contentBackground() - - fun sessionViewOutline(): Color = UiStyle.Colors.contentBorder() + /** Subtle hover fill, softer than the session-view outline. */ + fun headerHoverBgColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(headerBgColor(), Outline.hoverColor(), HOVER_FILL_ALPHA) + } + } - fun surface(): Color = UiStyle.Colors.editorBackground() + object Outline { + fun color(): Color = UiStyle.Colors.contentBorder() - fun header(): Color = UiStyle.Colors.editorBackground() + fun brightColor(): Color = JBColor.lazy { + UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) + } - /** Subtle hover fill, softer than the session-view outline. */ - fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), hoverLine(), HOVER_FILL_ALPHA) } + /** Subtle hover outline, stronger than the hover fill. */ + fun hoverColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(brightColor(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + } - /** Subtle hover outline, stronger than the hover fill. */ - fun hoverLine(): Color = JBColor.lazy { - UiStyle.Colors.blend(line(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + fun width(): Int = JBUI.scale(1) } - fun sessionView(color: Color = line()): Border = outline(color) - - fun outline(color: Color = line()): Border = JBUI.Borders.customLine(color, 1) - - fun topOutline(): Border = JBUI.Borders.customLineTop(line()) - /** Prompt input dimensions and chrome inside the session view. */ object Prompt { const val EDITOR_LINES = 3 @@ -74,6 +80,9 @@ object SessionUiStyle { /** Reasoning block preview sizing. */ object Reasoning { const val BODY_LINES = 5 + const val HEADER_VERTICAL_PADDING = 5 + const val BODY_VERTICAL_PADDING = 4 + const val BODY_HORIZONTAL_PADDING = 8 } /** Message container roles and user bubble geometry. */ @@ -119,6 +128,12 @@ object SessionUiStyle { } } + object AccountPopup { + fun bgColor(): Color = UiStyle.Colors.contentBackground() + + fun outlineColor(): Color = UiStyle.Colors.contentBorder() + } + /** Limits for the empty-state recent sessions list. */ object RecentSessions { const val LIMIT = 5 @@ -140,7 +155,13 @@ object SessionUiStyle { /** Border presets for connection dock panel. */ object Dock { fun banner(): Border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(SessionUiStyle.View.line()), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()), )!! } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index 25435404d9b..ece0854420a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -40,7 +40,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par applyStyle(SessionEditorStyle.current()) val line = { JPanel().apply { - background = SessionUiStyle.View.line() + background = SessionUiStyle.View.Outline.color() isOpaque = true preferredSize = JBDimension(0, JBUI.scale(1)) } } 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 5b71c23b5e6..d0b5e06b445 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 @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content 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.Tool import ai.kilocode.client.session.model.ToolCallRef @@ -18,6 +19,7 @@ import com.intellij.util.ui.JBUI import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import javax.swing.JComponent /** * A single message container inside a [TurnView]. @@ -36,6 +38,9 @@ class MessageView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { @@ -48,6 +53,11 @@ class MessageView( get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default private val parts = LinkedHashMap() + // Adjacent reasoning parts render through the first ReasoningView. aliases maps each + // merged child id to that owner id, and sources stores the child's latest full text + // so snapshot updates can append only deltas. + private val aliases = LinkedHashMap() + private val sources = LinkedHashMap() private var hidden: ToolCallRef? = null init { @@ -59,10 +69,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = view(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } } @@ -81,8 +88,11 @@ class MessageView( if (content is StepFinish) return if (isHidden(content)) { // Remove any stale view for this content so it disappears when suppressed - val stale = parts.remove(content.id) + val id = aliases.remove(content.id) + sources.remove(content.id) + val stale = if (id == null) parts.remove(content.id) else null if (stale != null) { + detach(stale) remove(stale) Disposer.dispose(stale) syncBorder() @@ -90,6 +100,16 @@ class MessageView( } return } + val id = aliases[content.id] + if (id != null && content is Reasoning) { + updateAlias(content, id) + refresh() + return + } + if (id != null) { + aliases.remove(content.id) + sources.remove(content.id) + } val existing = parts[content.id] if (existing != null) { if (ViewFactory.shouldReplace(existing, content)) { @@ -100,20 +120,56 @@ class MessageView( refresh() return } + addPart(content) + syncBorder() + refresh() + } + + private fun addPart(content: Content) { + if (content is Reasoning) { + val previous = parts.values.lastOrNull() + if (previous is ReasoningView) { + aliases[content.id] = previous.contentId + sources[content.id] = content.content.toString() + previous.update(merged(previous, content, content.content.toString())) + return + } + } val view = view(content) + view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view add(view) - syncBorder() - refresh() + } + + private fun updateAlias(content: Reasoning, id: String) { + val view = parts[id] as? ReasoningView ?: return + 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 + view.update(merged(view, content, delta)) + } + + private fun merged(view: ReasoningView, content: Reasoning, delta: String) = Reasoning(view.contentId).also { + it.done = content.done + it.content.append(view.markdown()) + it.content.append(delta) } private fun replacePart(content: Content, existing: PartView) { val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) + aliases.values.removeAll { it == content.id } + sources.keys.removeAll { it !in aliases } + detach(existing) remove(existing) Disposer.dispose(existing) val view = view(content) + view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view add(view, at) @@ -123,7 +179,14 @@ class MessageView( /** Remove the renderer for [contentId] if present. */ fun removePart(contentId: String) { + if (aliases.remove(contentId) != null) { + sources.remove(contentId) + return + } val view = parts.remove(contentId) ?: return + aliases.values.removeAll { it == contentId } + sources.keys.removeAll { it !in aliases } + detach(view) remove(view) Disposer.dispose(view) syncBorder() @@ -150,17 +213,17 @@ class MessageView( */ private fun rebuildParts() { parts.values.forEach { + detach(it) remove(it) Disposer.dispose(it) } parts.clear() + aliases.clear() + sources.clear() for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = view(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } syncBorder() refresh() @@ -172,20 +235,22 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection) + ViewFactory.createUser(content, openFile, openUrl, selection, repo) } else { - ViewFactory.create(content, openFile, openUrl, selection) + ViewFactory.create(content, openFile, openUrl, selection, repo) } /** Append a streaming delta to the renderer for [contentId]. */ fun appendDelta(contentId: String, delta: String): Boolean { - val part = parts[contentId] ?: return false + val id = aliases[contentId] + if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta + val part = parts[id ?: contentId] ?: return false part.appendDelta(delta) return true } /** Look up a renderer by part id. */ - fun part(id: String): PartView? = parts[id] + fun part(id: String): PartView? = parts[aliases[id] ?: id] /** Ordered part ids — stable for test assertions. */ fun partIds(): List = parts.keys.toList() @@ -202,10 +267,13 @@ class MessageView( override fun dispose() { parts.values.forEach { + detach(it) remove(it) Disposer.dispose(it) } parts.clear() + aliases.clear() + sources.clear() hidden = null } @@ -220,7 +288,7 @@ class MessageView( val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) g2.color = style.editorScheme.defaultBackground g2.fillRoundRect(0, 0, width, height, arc, arc) - g2.color = SessionUiStyle.View.line() + g2.color = SessionUiStyle.View.Outline.brightColor() val w = width - 1 val h = height - 1 if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc) @@ -235,5 +303,10 @@ class MessageView( repaint() } + private fun detach(view: PartView) { + view.setHovered(false) + view.hover = null + } + private fun assistantBorder() = JBUI.Borders.empty() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 9907738612f..21afb1fcab7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -12,10 +12,10 @@ import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory -import com.intellij.icons.AllIcons import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Dimension @@ -24,6 +24,7 @@ import java.awt.Rectangle import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable +import javax.swing.SwingUtilities /** Renders reasoning as a secondary collapsible block. */ class ReasoningView( @@ -32,11 +33,17 @@ class ReasoningView( private val selection: SessionSelection? = null, private val parts: ReasoningParts = reasoningParts(selection), ) : - SecondarySessionPartView(parts.header, { parts.scroll(openUrl) }) { + SecondarySessionPartView( + parts.header, + { parts.scroll(openUrl) }, + expanded = reasoning.content.isNotBlank() && !reasoning.done, + ) { override val contentId: String = reasoning.id + /** Lazily creates, registers, populates, and styles the editor-backed body on first access. */ val md: MdView + @RequiresEdt get() { val fresh = !parts.bodyCreated() val view = parts.md(openUrl) @@ -50,52 +57,97 @@ class ReasoningView( private var style = SessionEditorStyle.current() private var source = reasoning.content.toString() + private var done = reasoning.done private var registered = false + private var following = false init { + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) bindHeader(parts.title, parts.icon) applyStyle(style) + if (bodyVisible()) syncBody() + syncBorder() sync() } + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() if (!changed) return false + syncBorder() syncBody() applyBodyStyle() return true } + @RequiresEdt + override fun collapse(): Boolean { + val changed = super.collapse() + if (!changed) return false + syncBorder() + return true + } + + @RequiresEdt override fun update(content: Content) { if (content !is Reasoning) return var changed = false val next = content.content.toString() + val follow = tailVisible() + if (done != content.done) { + done = content.done + changed = true + } if (source != next) { source = next - if (parts.bodyCreated()) md.set(source) + if (parts.bodyCreated()) { + md.set(source) + followTail(follow) + } changed = true } changed = sync() || changed if (changed) refresh() } + @RequiresEdt override fun appendDelta(delta: String) { if (delta.isEmpty()) return + val follow = tailVisible() source += delta - if (parts.bodyCreated()) md.append(delta) + if (parts.bodyCreated()) { + md.append(delta) + followTail(follow) + } val changed = sync() if (changed || bodyVisible()) refresh() } + @RequiresEdt fun markdown(): String = source + @RequiresEdt fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt fun headerText(): String = parts.title.text + @RequiresEdt internal fun headerFont() = parts.title.font + @RequiresEdt internal fun bodyVisible() = parts.scrollOrNull?.parent === this + @RequiresEdt internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES + @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyScrollValue() = parts.scrollOrNull?.verticalScrollBar?.value ?: 0 + @RequiresEdt + internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false @@ -107,6 +159,7 @@ class ReasoningView( if (changed) refresh() } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -116,7 +169,34 @@ class ReasoningView( private fun canExpand(): Boolean = source.isNotBlank() - private fun sync(): Boolean = syncExpandable(canExpand()) + private fun sync(): Boolean { + var changed = false + val visible = source.isNotBlank() + if (isVisible != visible) { + isVisible = visible + changed = true + } + changed = syncExpandable(canExpand()) || changed + if (visible && !done && !parts.bodyCreated()) { + changed = expand() || changed + changed = syncExpandable(canExpand()) || changed + } + return changed + } + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + 0, + SessionUiStyle.View.Outline.width(), + 0, + 0, + ) + return + } + border = JBUI.Borders.empty(0, 1, 0, 0) + } private fun apply(md: MdView): Boolean { var changed = false @@ -130,10 +210,12 @@ class ReasoningView( return changed } + @RequiresEdt private fun syncBody() { val md = md registerBody(md) md.set(source) + followTail(true) } private fun applyBodyStyle(): Boolean { @@ -154,7 +236,28 @@ class ReasoningView( if (!parts.bodyCreated()) return 0 val md = md return md.component.getFontMetrics(md.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + @RequiresEdt + private fun tailVisible(): Boolean { + if (!bodyVisible()) return false + val scroll = parts.scrollOrNull ?: return false + val bar = scroll.verticalScrollBar + return bar.value >= bar.maximum - bar.visibleAmount + } + + @RequiresEdt + private fun followTail(follow: Boolean) { + if (!follow || !bodyVisible() || following) return + val scroll = parts.scrollOrNull ?: return + following = true + SwingUtilities.invokeLater { + following = false + if (!bodyVisible()) return@invokeLater + val bar = scroll.verticalScrollBar + bar.value = bar.maximum - bar.visibleAmount + } } override fun dumpLabel(): String { @@ -187,18 +290,18 @@ class ReasoningParts( } val panel = TrackPanel().apply { isOpaque = true - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), ) add(md.component, BorderLayout.CENTER) } val scroll = JBScrollPane(panel).apply { - border = SessionUiStyle.View.topOutline() + border = JBUI.Borders.empty() isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED } @@ -214,8 +317,8 @@ class ReasoningBody( private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } - val icon = JBLabel(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() } - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val icon = JBLabel(SessionViewIcons.eye).apply { foreground = UiStyle.Colors.weak() } + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(icon, BorderLayout.WEST) add(title, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt new file mode 100644 index 00000000000..c59f5d371fb --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.views + +import com.intellij.openapi.util.IconLoader + +object SessionViewIcons { + val brain = icon("brain") + val bubble = icon("bubble-5") + val bulletList = icon("bullet-list") + val checklist = icon("checklist") + val chevronDown = icon("chevron-down") + val chevronLeft = icon("chevron-left") + val chevronRight = icon("chevron-right") + val code = icon("code") + val codeLines = icon("code-lines") + val console = icon("console") + val eye = icon("eye") + val glasses = icon("glasses") + val mcp = icon("mcp") + val search = icon("magnifying-glass-menu") + val task = icon("task") + val warning = icon("warning") + val windowCursor = icon("window-cursor") + + private fun icon(name: String) = IconLoader.getIcon("/icons/views/$name.svg", SessionViewIcons::class.java) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 1e4358415ef..fa56f6466c2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -73,7 +73,7 @@ open class TextView( protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont - protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.View.transcript() + protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Transcript.bgColor() private fun refresh() { revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt deleted file mode 100644 index 5951f40ff00..00000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ /dev/null @@ -1,655 +0,0 @@ -@file:Suppress("TooManyFunctions") - -package ai.kilocode.client.session.views - -import ai.kilocode.client.plugin.KiloBundle -import ai.kilocode.client.session.model.Content -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.SessionEditorStyle -import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView -import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.components.JBTextArea -import com.intellij.util.ui.JBUI -import com.intellij.xml.util.XmlStringUtil -import java.awt.BorderLayout -import java.awt.CardLayout -import java.awt.Color -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Box -import javax.swing.Icon -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.ScrollPaneConstants - -/** Renders non-read tool calls with VS Code-inspired rows/cards. */ -class ToolView( - tool: Tool, - private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool), -) : - SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - private var registered = false - - init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - applyStyle(style) - sync() - } - - override fun expand(): Boolean { - val changed = super.expand() - if (!changed) return false - syncBody() - applyBodyStyle() - return true - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - val was = item.name - item = content - var changed = false - if (was != content.name || !canExpand(content)) changed = collapse() || changed - changed = sync() || changed - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - - fun commandText(): String = command(item) - - fun outputText(): String = output(item) - fun bodyText(): String = body(item) - internal fun previewText(): String = parts.text?.text ?: preview(item) - fun hasToggle(): Boolean = arrow.isVisible - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun titleFont() = parts.title.font - internal fun subtitleFont() = parts.sub.font - internal fun stateFont() = parts.state.font - internal fun bodyEditable() = parts.text?.isEditable ?: false - internal fun bodyCaretVisible() = parts.text?.caret?.isVisible ?: false - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun controlCount() = if (arrow.isVisible) 1 else 0 - internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyWrap() = parts.text?.lineWrap ?: true - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyCreated() = parts.bodyCreated() - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - var changed = false - changed = setFont(parts.title, style.boldEditorFont) || changed - changed = setFont(parts.sub, style.smallEditorFont) || changed - changed = setFont(parts.link, style.smallEditorFont) || changed - changed = setFont(parts.state, style.smallEditorFont) || changed - changed = applyBodyStyle() || changed - if (changed) refresh() - } - - private fun sync(): Boolean { - val expand = canExpand(item) - var changed = false - changed = syncExpandable(expand) || changed - changed = setVisible(parts.state, !expand) || changed - changed = syncLabels() || changed - val text = parts.text - if (text != null) changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun syncLabels(): Boolean { - var changed = false - changed = setIcon(parts.glyph, icon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed - changed = setText(parts.title, title(item)) || changed - changed = setText(parts.sub, subtitle(item)) || changed - changed = setForeground(parts.title, titleColor(item)) || changed - changed = setText(parts.state, stateText(item)) || changed - changed = setForeground(parts.state, color(item)) || changed - return changed - } - - private fun syncBody(): Boolean { - var changed = false - val text = parts.text ?: return false - val value = preview(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 - changed = true - } - changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun applyBodyStyle(): Boolean { - val text = parts.text ?: return false - if (!registered && selection != null && text.parent != null) { - registered = true - selection.register(text, this) - } - return setFont(text, style.transcriptFont) - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ToolView#$contentId(${labelText()})" -} - -/** Renders read calls with secondary, borderless chrome. */ -class ReadToolView( - tool: Tool, - openFile: (String) -> Unit = {}, - private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool, openFile), -) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { - - companion object { - fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ - } - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - - init { - parts.text?.let { selection?.register(it, this) } - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - parts.text?.text = preview(item) - applyStyle(style) - sync() - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - item = content - var changed = sync() - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - fun bodyText(): String = body(item) - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun hasToggle() = arrow.isVisible - internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun linkVisible() = parts.link.isVisible - internal fun linkText() = parts.label - internal fun linkMarkup() = parts.link.text ?: "" - internal fun linkForeground() = parts.link.foreground - internal fun linkFont() = parts.link.font - internal fun subtitleForeground() = parts.sub.foreground - internal fun subtitleFont() = parts.sub.font - internal fun linkHref() = parts.href - internal fun openLink() = parts.openLink() - - 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 - parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } - if (changed) refresh() - } - - private fun sync(): Boolean { - var changed = false - changed = syncExpandable(false) || changed - changed = setVisible(parts.state, true) || changed - changed = setIcon(parts.glyph, icon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed - changed = setText(parts.title, title(item)) || changed - changed = syncSubtitle() || changed - changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed - changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed - changed = setText(parts.state, stateText(item)) || changed - changed = setForeground(parts.state, color(item)) || changed - parts.text?.let { changed = setForeground(it, bodyColor()) || changed } - return changed - } - - 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 - } - changed = setText(parts.sub, subtitle(item)) || changed - changed = show(parts, false) || changed - return changed - } - - private fun syncBody(): Boolean { - val value = preview(item) - val text = parts.text ?: return false - if (text.text == value) return false - text.text = value - text.caretPosition = 0 - return true - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" -} - -class ToolParts( - val header: JPanel, - val glyph: JBLabel, - val title: JBLabel, - val sub: JBLabel, - val link: JBLabel, - val slot: JPanel, - val state: JBLabel, - val center: JPanel, - val controls: JComponent, - private val open: ((String) -> Unit)? = null, -) { - var href: String? = null - var label: String = "" - private var body: ToolBody? = null - - val text: JBTextArea? - get() = body?.text - - val scroll: JBScrollPane? - get() = body?.scroll - - fun scroll(tool: Tool): JBScrollPane = body(tool).scroll - - fun bodyCreated() = body != null - - fun openLink() { - val value = href ?: return - open?.invoke(value) - } - - private fun body(tool: Tool): ToolBody { - val item = body - if (item != null) return item - val text = JBTextArea().apply { - isEditable = false - caret.isVisible = false - caret.isSelectionVisible = true - lineWrap = true - wrapStyleWord = true - foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), - ) - } - val scroll = JBScrollPane(text).apply { - border = SessionUiStyle.View.topOutline() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - return ToolBody(text, scroll).also { body = it } - } -} - -class ToolBody( - val text: JBTextArea, - val scroll: JBScrollPane, -) - -private const val SUB_CARD = "sub" -private const val LINK_CARD = "link" - -private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts { - lateinit var parts: ToolParts - val glyph = JBLabel() - val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val link = 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() - } - }) - } - val slot = JPanel(CardLayout()).apply { - isOpaque = false - add(sub, SUB_CARD) - add(link, LINK_CARD) - } - val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false } - val controls = Box.createHorizontalBox() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(slot, BorderLayout.CENTER) - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile) - return parts.also { - controls.add(it.state) - } -} - -private fun icon(tool: Tool) = when (tool.name) { - "read" -> AllIcons.Actions.Preview - "bash" -> AllIcons.Debugger.Console - else -> when (tool.state) { - ToolExecState.PENDING -> AllIcons.Process.Step_1 - ToolExecState.RUNNING -> AllIcons.Process.Step_2 - ToolExecState.COMPLETED -> AllIcons.Actions.Checked - ToolExecState.ERROR -> AllIcons.General.Error - } -} - -private fun title(tool: Tool) = when (tool.name) { - "read" -> KiloBundle.message("session.part.tool.read") - "bash" -> KiloBundle.message("session.part.tool.shell") - else -> toolTitle(tool) -} - -private fun subtitle(tool: Tool) = when (tool.name) { - "read" -> readPath(tool) - "bash" -> shellTitle(tool) - else -> toolSubtitle(tool) -} - -private fun setText(label: JBLabel, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) - if (label.text == value) return false - label.text = value - return true -} - -private fun setLinkText(parts: ToolParts, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") - if (parts.label == text && parts.link.text == value) return false - parts.label = text - parts.link.text = value - return true -} - -private 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 -} - -private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text - -private fun setIcon(label: JBLabel, icon: Icon): Boolean { - if (label.icon === icon) return false - label.icon = icon - return true -} - -private fun setVisible(component: JComponent, visible: Boolean): Boolean { - if (component.isVisible == visible) return false - component.isVisible = visible - return true -} - -private fun setForeground(component: JComponent, color: Color): Boolean { - if (same(component.foreground, color)) return false - component.foreground = color - return true -} - -private fun setFont(component: JComponent, font: Font): Boolean { - if (component.font == font) return false - component.font = font - return true -} - -private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb - -private fun color(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() - ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() - ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() - ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() -} - -private fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { - UiStyle.Colors.errorLabelForeground() -} else { - UiStyle.Colors.fg() -} - -private fun stateText(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") - ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") - ToolExecState.COMPLETED -> "" - ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") -} - -private fun readPath(tool: Tool): String { - val target = target(tool) - if (target != null) { - if (target.type == "file") return tail(target.path).ifBlank { target.path } - return target.path - } - val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name - return tail(path).ifBlank { path } -} - -private data class Target( - val path: String, - val type: String, -) - -private fun target(tool: Tool): Target? { - val out = output(tool) - if (out.isBlank()) return null - val path = tag(out, "path") ?: return null - val type = tag(out, "type") ?: return null - return Target(path, type.lowercase()) -} - -private fun tag(text: String, name: String): String? = - Regex("<$name>\\s*([\\s\\S]*?)\\s*") - .find(text) - ?.groupValues - ?.getOrNull(1) - ?.trim() - ?.takeIf { it.isNotBlank() } - -private fun shellTitle(tool: Tool): String = - tool.input["description"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } - ?: tool.title?.takeIf { it.isNotBlank() } - ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } - ?: "" - -private fun command(tool: Tool): String = - tool.input["command"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun output(tool: Tool): String = - tool.output?.takeIf { it.isNotBlank() } - ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) - -private fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) - -private fun shellPreview(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - sep() - append(out) - } - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun shellBody(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return buildString { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - if (isNotEmpty()) append("\n\n") - append(out) - } - if (err != null) { - if (isNotEmpty()) append("\n\n") - append(err) - } - } -} - -private fun plainPreview(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (out.isNotBlank()) append(out) - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun plainBody(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") -} - -private fun canExpand(tool: Tool): Boolean { - if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() - return output(tool).isNotBlank() || !tool.error.isNullOrBlank() -} - -private fun toolTitle(tool: Tool): String = - tool.title?.takeIf { it.isNotBlank() } - ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } - -private fun toolSubtitle(tool: Tool): String { - val base = listOf("description", "query", "url", "filePath", "path", "name") - .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } - .firstOrNull() - val args = listOf("pattern", "include", "offset", "limit") - .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } - return listOfNotNull(base).plus(args).joinToString(" ") -} - -private fun tail(path: String): String { - val value = path.trimEnd('/', '\\') - val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) - if (index < 0) return value - return value.substring(index + 1) -} - -private class Preview { - private val text = StringBuilder() - private var cut = false - - fun append(value: String): Preview { - if (cut) return this - val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length - if (value.length <= rem) { - text.append(value) - return this - } - if (rem > 0) text.append(value, 0, rem) - cut = true - return this - } - - fun sep(): Preview { - if (text.isNotEmpty()) append("\n\n") - return this - } - - fun build(): String { - if (!cut) return text.toString() - if (text.isNotEmpty()) text.append("\n\n") - text.append(KiloBundle.message("session.part.tool.truncated")) - return text.toString() - } -} 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 5320b21dbf0..459509ffc93 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 @@ -6,9 +6,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget 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.util.ui.JBUI +import javax.swing.JComponent /** * Top-level transcript item representing one conversational turn. @@ -25,6 +27,9 @@ class TurnView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -37,7 +42,7 @@ class TurnView( /** 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) + val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo, hover) messages[msg.info.id] = view add(view) revalidate() 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 fca054a0c93..a13dde6e1ce 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 @@ -3,6 +3,10 @@ package ai.kilocode.client.session.views 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.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content @@ -25,19 +29,20 @@ object ViewFactory { fun create( content: Content, openFile: (String) -> Unit, - ): PartView = create(content, openFile, openUrl = {}, selection = null) + ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit, - ): PartView = create(content, openFile, openUrl, selection = null) + ): PartView = create(content, openFile, openUrl, selection = null, repo = null) fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, + repo: String? = null, ): PartView = when (content) { is Text -> TextView(content, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) @@ -45,6 +50,8 @@ object ViewFactory { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + 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) else -> ToolView(content, selection = selection) } @@ -56,22 +63,23 @@ object ViewFactory { fun createUser( content: Content, openFile: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl = {}, selection = null) + ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl, selection = null) + ): PartView = createUser(content, openFile, openUrl, selection = null, repo = null) fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, + repo: String? = null, ): PartView = when (content) { is Text -> PromptView(content, openUrl = openUrl, selection = selection) - else -> create(content, openFile, openUrl, selection) + else -> create(content, openFile, openUrl, selection, repo) } /** @@ -86,6 +94,10 @@ object ViewFactory { if (view is PlanExitView) return !PlanExitView.canRender(content) if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) + if (view is GlobToolView) return !GlobToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is GlobToolView && GlobToolView.canRender(content)) return true + if (view is SearchToolView) return !SearchToolView.canRender(content) || QuestionResultView.canRender(content) + 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 ToolView) return QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 52942a60677..ac93b08e834 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.icons.AllIcons +import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -29,7 +29,7 @@ abstract class AbstractSessionPartView( ) : this(header, { body }, expanded, expandable) protected val arrow = JBLabel() - protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) + protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) private val bound = linkedSetOf() private var body: JComponent? = null @@ -41,12 +41,12 @@ abstract class AbstractSessionPartView( } private val mouse = object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { - setHover(true) + setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -65,7 +65,7 @@ abstract class AbstractSessionPartView( fun toggle() { if (!expandable || !arrow.isVisible) return - val changed = if (isExpanded()) collapse() else expand() + val changed = toggleLocal() if (!changed) return syncArrow() refresh() @@ -78,7 +78,7 @@ abstract class AbstractSessionPartView( return true } - fun collapse(): Boolean { + open fun collapse(): Boolean { val item = body ?: return false if (item.parent !== this) return false remove(item) @@ -89,6 +89,15 @@ abstract class AbstractSessionPartView( protected fun bodyComponent(): JComponent = body() + private fun toggleLocal(): Boolean { + val fn = resize ?: return toggleBody() + val expanded = isExpanded() + fn(this) { toggleBody() } + return expanded != isExpanded() + } + + private fun toggleBody(): Boolean = if (isExpanded()) collapse() else expand() + fun syncExpandable(expandable: Boolean): Boolean { val active = this.expandable && expandable val changed = setVisible(arrow, active) @@ -110,13 +119,11 @@ abstract class AbstractSessionPartView( protected open fun hoverColor(value: Boolean): Color? = null - protected open fun applyHover(value: Boolean, color: Color) {} - - private fun setHover(value: Boolean) { + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) val color = hoverColor(value) ?: return if (row.background?.rgb == color.rgb) return row.background = color - applyHover(value, color) row.repaint() } @@ -150,7 +157,7 @@ abstract class AbstractSessionPartView( } private fun syncArrow(): Boolean { - val icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + val icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight if (arrow.icon === icon) return false arrow.icon = icon return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 5b14587de93..3a73b355a65 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -41,6 +41,8 @@ import javax.swing.JPanel class BaseQuestionView( private val selection: SessionSelection? = null, ) : RoundedContentPanel( + UiStyle.Gap.pad(), + UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad(), ), SessionEditorStyleTarget { @@ -95,6 +97,7 @@ class BaseQuestionView( private var top: JComponent? = null private var content: JComponent? = null private var actionLeft: JComponent? = null + private var gap = UiStyle.Gap.lg() // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() @@ -184,6 +187,15 @@ class BaseQuestionView( repaint() } + @RequiresEdt + fun setSpacing(top: Int, gap: Int) { + this.gap = gap + border = JBUI.Borders.empty(top, UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) + syncNorth() + revalidate() + repaint() + } + /** * Configure the action buttons shown in the card's right-aligned footer. * @@ -275,9 +287,9 @@ class BaseQuestionView( // ---- contentColor override ---- - override fun contentColor(): Color = SessionUiStyle.View.surface() + override fun contentColor(): Color = SessionUiStyle.View.Surface.bgColor() - override fun outlineColor(): Color = SessionUiStyle.View.line() + override fun outlineColor(): Color = SessionUiStyle.View.Outline.brightColor() // ---- private helpers ---- @@ -285,7 +297,7 @@ class BaseQuestionView( north.removeAll() top?.let { north.next(it) } north.next(header) - if (content != null) north.fill(UiStyle.Gap.md()) + if (content != null) north.fill(gap) north.revalidate() north.repaint() } @@ -383,7 +395,7 @@ class BaseQuestionView( } private fun syncBackground() { - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() } } btn.addActionListener { actionHandlers[id]?.invoke() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index c0973f9d166..7f535048a70 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -26,6 +26,7 @@ class GenericView private constructor( label.foreground = UiStyle.Colors.weak() applyStyle(SessionEditorStyle.current()) syncExpandable(false) + border = null } override fun update(content: Content) {} // generic content has no updatable state diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 41d4d57853a..b1b0256110f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import com.intellij.openapi.Disposable +import javax.swing.JComponent import javax.swing.JPanel /** @@ -20,6 +21,10 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { /** Stable [Content.id] this renderer was created for. */ abstract val contentId: String + var resize: ((JComponent, () -> Unit) -> Unit)? = null + + var hover: ((PartView, Boolean) -> Unit)? = null + /** * Apply a full content update — replace, not append. * Called when [ai.kilocode.client.session.model.SessionModelEvent.ContentUpdated] fires. @@ -33,6 +38,8 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { */ open fun appendDelta(delta: String) {} + open fun setHovered(value: Boolean) {} + override fun applyStyle(style: SessionEditorStyle) {} override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt index 6a4a1d89f67..d82a3e11628 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI -import java.awt.Color import javax.swing.JComponent abstract class PrimarySessionPartView( @@ -13,20 +12,36 @@ abstract class PrimarySessionPartView( ) : AbstractSessionPartView(header, content, expanded, expandable) { init { isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.sessionView() + background = SessionUiStyle.View.Surface.bgColor() row.isOpaque = true - row.background = SessionUiStyle.View.header() + row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) + syncBorder() } - override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed + } + + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() - override fun applyHover(value: Boolean, color: Color) { - border = if (value) SessionUiStyle.View.sessionView(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.sessionView() - repaint() + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return + } + border = JBUI.Borders.empty(1) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index 8951a50d808..d0a0d3808e5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -19,12 +19,34 @@ abstract class SecondarySessionPartView( ) : this(header, { content }, expanded, expandable) init { row.isOpaque = true - row.background = SessionUiStyle.View.header() + row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) + syncBorder() } - override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed + } + + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return + } + border = JBUI.Borders.empty(1) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d6584f0f0ba..e8f52e97dec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -10,14 +10,13 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.ui.style.SessionUiStyle.View.SESSION_VIEW_GAP +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.ColorUtil @@ -68,7 +67,7 @@ class PermissionView( isOpaque = false isVisible = false - card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title")) + card.setHeaderIcon(SessionViewIcons.warning, KiloBundle.message("session.permission.title")) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") }, @@ -128,7 +127,7 @@ class PermissionView( /** Adds a three-column permission detail row: tool, target, and changes. */ private fun addDetailRow(action: String, target: String?, diffs: List) { - val row = JPanel(BorderLayout(SESSION_VIEW_GAP, 0)).apply { + val row = JPanel(BorderLayout(SessionUiStyle.View.Layout.GAP, 0)).apply { isOpaque = false } @@ -174,7 +173,7 @@ class PermissionView( private fun applyTargetPane(pane: JBHtmlPane) { pane.font = style.transcriptFont pane.foreground = style.editorForeground - pane.background = SessionUiStyle.View.headerHover() + pane.background = SessionUiStyle.View.Surface.headerHoverBgColor() pane.reloadCssStylesheets() } @@ -182,7 +181,7 @@ class PermissionView( val sheet = StyleSheet() val font = style.transcriptFont val fg = ColorUtil.toHtmlColor(style.editorForeground) - val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.Surface.headerHoverBgColor()) val family = font.name.replace("\\", "\\\\").replace("'", "\\'") sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }") sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index 6672c9cf647..f1c241141f8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -6,10 +6,10 @@ 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.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PartView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel @@ -41,26 +41,26 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.sessionView() + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty(1) } } - private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) { + private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.header() + background = SessionUiStyle.View.Surface.headerBgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) } } - private val glyph = JBLabel(AllIcons.General.Balloon) + private val glyph = JBLabel(SessionViewIcons.bubble) private val title = JBLabel() private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } private var pane: JPanel? = null @@ -70,10 +70,10 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { setHover(true) } + override fun mouseEntered(e: MouseEvent) { setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -99,6 +99,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = add(root, BorderLayout.CENTER) syncLabels() syncArrow() + syncBorder() } override fun update(content: Content) { @@ -122,13 +123,18 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } fun toggle() { + resize?.invoke(this) { toggleBody() } ?: toggleBody() + syncArrow() + refresh() + } + + private fun toggleBody() { if (isExpanded()) { pane?.let { root.remove(it) } } else { root.add(body(), BorderLayout.CENTER) } - syncArrow() - refresh() + syncBorder() } fun isExpanded(): Boolean = pane?.parent === root @@ -164,10 +170,19 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.compound( + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), + JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ), ) } }.apply { @@ -270,16 +285,28 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private fun syncArrow() { - arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + arrow.icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight } - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (header.background?.rgb == color.rgb) return - header.background = color - root.border = if (value) SessionUiStyle.View.sessionView(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.sessionView() - header.repaint() - root.repaint() + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) + val color = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + if (header.background?.rgb != color.rgb) { + header.background = color + header.repaint() + } + } + + private fun syncBorder() { + if (isExpanded()) { + root.border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + ) + return + } + root.border = JBUI.Borders.empty(1) } private fun inside(e: MouseEvent): Boolean { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index b924a78b30f..42c61bdb25d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -13,7 +14,6 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project @@ -79,14 +79,14 @@ class QuestionView( layout = BoxLayout(this, BoxLayout.X_AXIS) } private val back = HoverIcon().apply { - val ico = AllIcons.Actions.Back + val ico = SessionViewIcons.chevronLeft icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.back") addActionListener { goBack() } } private val fwd = HoverIcon().apply { - val ico = AllIcons.Actions.Forward + val ico = SessionViewIcons.chevronRight icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.next") @@ -94,7 +94,7 @@ class QuestionView( } private val topPanel = JPanel(BorderLayout()).apply { isOpaque = false - border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + border = JBUI.Borders.empty() alignmentX = Component.LEFT_ALIGNMENT } private val body = JPanel().apply { @@ -207,6 +207,12 @@ class QuestionView( summary.isVisible = total > 1 nav.isVisible = total > 1 topPanel.isVisible = total > 1 + if (total > 1) { + topPanel.border = JBUI.Borders.empty(0, 0, UiStyle.Gap.sm(), 0) + card.setSpacing(UiStyle.Gap.sm(), UiStyle.Gap.pad()) + return + } + card.setSpacing(UiStyle.Gap.xl(), UiStyle.Gap.pad()) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 7dc9cb8a400..a2dcc25d158 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -6,9 +6,9 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -28,7 +28,13 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : init { bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) parts.list.border = JBUI.Borders.compound( - SessionUiStyle.View.topOutline(), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), ) applyStyle(style) @@ -92,16 +98,16 @@ class TodoParts( ) private fun todoParts(): TodoParts { - val glyph = JBLabel(AllIcons.Actions.Checked) + val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(title, BorderLayout.WEST) add(sub, BorderLayout.CENTER) } val controls = Box.createHorizontalBox() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(glyph, BorderLayout.WEST) add(center, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt new file mode 100644 index 00000000000..24d36cff643 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -0,0 +1,188 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +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.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.Icon + +abstract class BaseSearchToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts, + private val repo: String? = null, +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + protected var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + private var disposed = false + + protected abstract fun toolIcon(tool: Tool): Icon + protected abstract fun toolTitle(tool: Tool): String + protected abstract fun targets(tool: Tool, repo: String?): List + protected abstract fun viewName(): String + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.targets.forEach { bindHeader(it) } + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } + @RequiresEdt + internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun hasToggle() = arrow.isVisible + @RequiresEdt + internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt + internal fun titleFont() = parts.title.font + @RequiresEdt + internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.regularFont + @RequiresEdt + internal fun stateFont() = parts.state.font + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun scrollComponent() = parts.scroll + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy + @RequiresEdt + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy + @RequiresEdt + internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt + internal fun headerComponent() = parts.header + @RequiresEdt + internal fun centerComponent() = parts.center + @RequiresEdt + internal fun targetComponents() = parts.targets + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + parts.targets.forEach { changed = setFont(it, style.regularFont) || changed } + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, item.state != ToolExecState.COMPLETED) || changed + changed = setIcon(parts.glyph, toolIcon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, toolTitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = syncTargets() || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun syncTargets(): Boolean { + val values = targets(item, repo) + var changed = false + parts.targets.forEachIndexed { index, label -> + val text = values.getOrNull(index) ?: "" + changed = setVisible(label, text.isNotBlank()) || changed + changed = setTargetText(label, text) || changed + changed = setForeground(label, UiStyle.Colors.fg()) || changed + } + return changed + } + + private fun syncBody(): Boolean { + val body = parts.content ?: return false + val value = plainBody(item) + if (body.text != value) { + body.text = value + return true + } + return false + } + + private fun applyBodyStyle(): Boolean { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { + registered = true + body.register(selection, this) + } + return body.applyStyle(style) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val body = parts.content ?: return 0 + return body.lineHeight() * SessionUiStyle.View.Tool.BODY_LINES + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt new file mode 100644 index 00000000000..ccb204da04f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection + +/** Renders glob calls with a stacked, collapsible search-result header. */ +class GlobToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(2), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "glob" + } + + override fun toolIcon(tool: Tool) = icon(tool) + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") + override fun targets(tool: Tool, repo: String?) = listOf(globDirectory(tool, repo), globPattern(tool)).filter { it.isNotBlank() } + override fun viewName() = "GlobToolView" +} 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 new file mode 100644 index 00000000000..d2c2a623ead --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -0,0 +1,170 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +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.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders read calls with secondary, borderless chrome. */ +class ReadToolView( + tool: Tool, + openFile: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, openFile), +) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ + } + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + parts.text?.let { selection?.register(it, this) } + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.text?.text = preview(item) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun hasToggle() = arrow.isVisible + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyWrap() = parts.text?.lineWrap ?: false + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + @RequiresEdt + internal fun linkVisible() = parts.link.isVisible + @RequiresEdt + internal fun linkText() = parts.label + @RequiresEdt + internal fun linkMarkup() = parts.link.text ?: "" + @RequiresEdt + internal fun linkForeground() = parts.link.foreground + @RequiresEdt + internal fun linkFont() = parts.link.font + @RequiresEdt + internal fun subtitleForeground() = parts.sub.foreground + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun linkHref() = parts.href + @RequiresEdt + internal fun openLink() = parts.openLink() + + @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 + parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + var changed = false + changed = syncExpandable(false) || changed + changed = setVisible(parts.state, true) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = syncSubtitle() || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + return changed + } + + 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 + } + changed = setText(parts.sub, subtitle(item)) || changed + changed = show(parts, false) || changed + return changed + } + + private fun syncBody(): Boolean { + val value = preview(item) + val text = parts.text ?: return false + if (text.text == value) return false + text.text = value + text.caretPosition = 0 + return true + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt new file mode 100644 index 00000000000..83084495e6b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt @@ -0,0 +1,24 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.views.SessionViewIcons + +/** Renders grep/content-search calls with stacked, clipped search targets. */ +class SearchToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(3), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "grep" + } + + override fun toolIcon(tool: Tool) = SessionViewIcons.search + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") + override fun targets(tool: Tool, repo: String?) = searchTargets(tool, repo) + override fun viewName() = "SearchToolView" +} 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 new file mode 100644 index 00000000000..81744bfb98b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -0,0 +1,680 @@ +@file:Suppress("TooManyFunctions") + +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +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.SessionViewIcons +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.util.io.OSAgnosticPathUtil +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension +import com.intellij.util.ui.JBUI +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color +import java.awt.Cursor +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +private val LOG = KiloLog.create(ToolParts::class.java) + +enum class ToolBodyMode { EDITOR, TEXT } + +class ToolParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val link: JBLabel, + val slot: JPanel, + val state: JBLabel, + val center: JPanel, + val controls: JComponent, + private val open: ((String) -> Unit)? = null, + val extra: JBLabel? = null, + val targets: List = emptyList(), + private val mode: ToolBodyMode = ToolBodyMode.EDITOR, +) { + var href: String? = null + var label: String = "" + private var body: ToolBody? = null + + val text: JBTextArea? + @RequiresEdt + get() = body?.area + + val content: ToolBody? + @RequiresEdt + get() = body + + val scroll: JBScrollPane? + @RequiresEdt + get() = body?.scroll + + @RequiresEdt + fun scroll(tool: Tool): JBScrollPane = body(tool).scroll + + @RequiresEdt + fun bodyCreated() = body != null + + @RequiresEdt + fun openLink() { + val value = href ?: return + open?.invoke(value) + } + + @RequiresEdt + private fun body(tool: Tool): ToolBody { + val item = body + if (item != null) return item + val body = when (mode) { + ToolBodyMode.EDITOR -> ToolBody.editor(tool) + ToolBodyMode.TEXT -> ToolBody.text(tool) + } + return body.also { this.body = it } + } +} + +class ToolBody private constructor( + val area: JBTextArea?, + val ed: EditorTextField?, + val scroll: JBScrollPane, + private val disposable: Disposable?, +) : Disposable { + var text: String + @RequiresEdt + get() = area?.text ?: ed?.text ?: "" + @RequiresEdt + set(value) { + if (text == value) return + area?.text = value + ed?.text = value + caretStart() + size() + } + + var font: Font + @RequiresEdt + get() = area?.font ?: ed?.font ?: SessionEditorStyle.current().editorFont + @RequiresEdt + set(value) { + area?.font = value + ed?.font = value + size() + } + + var foreground: Color + @RequiresEdt + get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg() + @RequiresEdt + set(value) { + area?.foreground = value + ed?.foreground = value + } + + val editable: Boolean get() = area?.isEditable ?: false + val caretVisible: Boolean get() = area?.caret?.isVisible ?: false + val lineWrap: Boolean get() = area?.lineWrap ?: false + val editor: EditorTextField? get() = ed + + @RequiresEdt + fun caretStart() { + area?.caretPosition = 0 + ed?.getEditor(false)?.caretModel?.moveToOffset(0) + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = font + area?.font = style.transcriptFont + ed?.font = style.editorFont + ed?.getEditor(false)?.let(style::applyToEditor) + size() + return before != font + } + + @RequiresEdt + fun register(selection: SessionSelection, parent: Disposable) { + val field = ed + if (field != null) { + selection.register(field, parent) + return + } + area?.let { selection.register(it, parent) } + } + + @RequiresEdt + fun lineHeight(): Int = ed?.getEditor(false)?.lineHeight ?: scroll.viewport.view.getFontMetrics(font).height + + override fun dispose() { + disposable?.let(Disposer::dispose) + } + + private fun size() { + val view = scroll.viewport.view as? JComponent ?: return + val height = height(view) + val width = width(view) + view.preferredSize = JBUI.size(width, height) + view.minimumSize = JBUI.size(0, height) + view.maximumSize = JBDimension(Int.MAX_VALUE, height) + val inset = scroll.viewportBorder?.getBorderInsets(scroll) ?: JBUI.emptyInsets() + val pane = height + scroll.insets.top + scroll.insets.bottom + inset.top + inset.bottom + + scroll.horizontalScrollBar.preferredSize.height + scroll.preferredSize = JBUI.size(0, pane) + scroll.minimumSize = JBUI.size(0, pane) + scroll.maximumSize = JBDimension(Int.MAX_VALUE, pane) + } + + private fun width(view: JComponent): Int { + val metrics = view.getFontMetrics(font) + return (text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0) + + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun height(view: JComponent): Int { + ed?.ensureWillComputePreferredSize() + val rows = text.lineSequence().count().coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + return maxOf(view.preferredSize.height, lineHeight() * rows) + } + + companion object { + @RequiresEdt + fun editor(tool: Tool): ToolBody { + val disposable = Disposer.newDisposable("Tool body") + val body = runCatching { + val field = ToolField(preview(tool), SessionEditorStyle.current()).also { ed -> + ed.setDisposedWith(disposable) + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + } + ToolBody(null, field, pane(field, true), disposable) + }.getOrElse { err -> + LOG.warn("kind=tool codeEditor=true failed message=${err.message}", err) + val area = area(tool, false) + ToolBody(area, null, pane(area, true), disposable) + } + body.size() + return body + } + + @RequiresEdt + fun text(tool: Tool): ToolBody { + val area = area(tool, true) + val body = ToolBody(area, null, pane(area, false), null) + body.size() + return body + } + + private fun area(tool: Tool, wrap: Boolean) = JBTextArea().apply { + isEditable = false + caret.isVisible = false + caret.isSelectionVisible = true + lineWrap = wrap + wrapStyleWord = wrap + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + } + + private fun pane(view: JComponent, scrolls: Boolean) = JBScrollPane(view).apply { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ) + viewportBorder = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ).takeIf { scrolls } + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() + horizontalScrollBarPolicy = if (scrolls) { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } +} + +private class ToolField(value: String, private var style: SessionEditorStyle) : EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + PlainTextFileType.INSTANCE, + true, + false, +) { + init { + setFontInheritedFromLAF(false) + font = style.editorFont + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.background = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.viewport.background = SessionUiStyle.View.Surface.bgColor() + ed.settings.isUseSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } +} + +private const val SUB_CARD = "sub" +private const val LINK_CARD = "link" + +@RequiresEdt +internal fun toolParts( + tool: Tool, + openFile: ((String) -> Unit)? = null, + mode: ToolBodyMode = ToolBodyMode.TEXT, +): ToolParts { + lateinit var parts: ToolParts + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val link = 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() + } + }) + } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + center.add(title, BorderLayout.WEST) + center.add(slot, BorderLayout.CENTER) + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile, mode = mode) + return parts.also { + controls.add(it.state) + } +} + +@RequiresEdt +internal fun searchParts(count: Int): ToolParts { + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val targets = List(count) { + JBLabel().apply { + foreground = UiStyle.Colors.fg() + minimumSize = JBUI.size(0, minimumSize.height) + } + } + val link = JBLabel().apply { isVisible = false } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } + val target = stack.align(HAlign.TRACK, VAlign.CENTER) + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + minimumSize = JBUI.size(0, minimumSize.height) + add(title, BorderLayout.WEST) + add(target, BorderLayout.CENTER) + } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also { + controls.add(it.state) + } +} + +internal fun icon(tool: Tool) = when (tool.name) { + "read" -> SessionViewIcons.glasses + "list" -> SessionViewIcons.bulletList + "glob", "grep" -> SessionViewIcons.search + "webfetch", "websearch" -> SessionViewIcons.windowCursor + "codesearch" -> SessionViewIcons.code + "task" -> SessionViewIcons.task + "bash" -> SessionViewIcons.console + "edit", "write", "apply_patch" -> SessionViewIcons.codeLines + "todowrite", "todoread" -> SessionViewIcons.checklist + "question" -> SessionViewIcons.bubble + "skill" -> SessionViewIcons.brain + 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") + else -> toolTitle(tool) +} + +internal fun subtitle(tool: Tool) = when (tool.name) { + "read" -> readPath(tool) + "bash" -> shellTitle(tool) + else -> toolSubtitle(tool) +} + +@RequiresEdt +internal fun setText(label: JBLabel, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) + if (label.text == value) return false + label.text = value + return true +} + +@RequiresEdt +internal fun setTargetText(label: JBLabel, text: String): Boolean { + if (label.text == text) return false + label.text = text + return true +} + +@RequiresEdt +internal fun setLinkText(parts: ToolParts, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") + if (parts.label == text && parts.link.text == value) return false + parts.label = text + parts.link.text = value + return true +} + +@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 +} + +internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text + +@RequiresEdt +internal fun setIcon(label: JBLabel, icon: Icon): Boolean { + if (label.icon === icon) return false + label.icon = icon + return true +} + +@RequiresEdt +internal fun setVisible(component: JComponent, visible: Boolean): Boolean { + if (component.isVisible == visible) return false + component.isVisible = visible + return true +} + +@RequiresEdt +internal fun setForeground(component: JComponent, color: Color): Boolean { + if (same(component.foreground, color)) return false + component.foreground = color + return true +} + +@RequiresEdt +internal fun setFont(component: JComponent, font: Font): Boolean { + if (component.font == font) return false + component.font = font + return true +} + +private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb + +internal fun color(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() + ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() + ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() + ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() +} + +internal fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { + UiStyle.Colors.errorLabelForeground() +} else { + UiStyle.Colors.fg() +} + +internal fun stateText(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") + ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") + ToolExecState.COMPLETED -> "" + ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") +} + +private fun readPath(tool: Tool): String { + val target = target(tool) + if (target != null) { + if (target.type == "file") return tail(target.path).ifBlank { target.path } + return target.path + } + val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name + return tail(path).ifBlank { path } +} + +internal fun searchPath(path: String, repo: String?): String { + val text = path.takeIf { it.isNotBlank() } ?: return "" + val root = repo?.takeIf { it.isNotBlank() }?.let(::norm) + if (root == null) return text.takeUnless { it == "." } ?: "" + val full = if (OSAgnosticPathUtil.isAbsolute(text)) norm(text) else norm(FileUtil.join(root, text)) + if (full == root) return "" + if (!OSAgnosticPathUtil.startsWith(full, root)) return full + return FileUtil.getRelativePath(root, full, '/') ?: full +} + +private fun norm(path: String): String = FileUtil.toCanonicalPath(FileUtil.toSystemIndependentName(path), '/', true) + +internal fun globDirectory(tool: Tool, repo: String?): String = + searchPath( + tool.input["path"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: "", + repo, + ) + +internal fun globPattern(tool: Tool): String = + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" + +internal fun searchTargets(tool: Tool, repo: String?): List = listOfNotNull( + tool.input["path"]?.takeIf { it.isNotBlank() }?.let { searchPath(it, repo) }?.takeIf { it.isNotBlank() }, + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, + tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, +) + +internal data class Target( + val path: String, + val type: String, +) + +internal fun target(tool: Tool): Target? { + val out = output(tool) + if (out.isBlank()) return null + val path = tag(out, "path") ?: return null + val type = tag(out, "type") ?: return null + return Target(path, type.lowercase()) +} + +private fun tag(text: String, name: String): String? = + Regex("<$name>\\s*([\\s\\S]*?)\\s*") + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } + +private fun shellTitle(tool: Tool): String = + tool.input["description"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } + ?: "" + +internal fun command(tool: Tool): String = + tool.input["command"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun output(tool: Tool): String = + tool.output?.takeIf { it.isNotBlank() } + ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) + +internal fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) + +private fun shellPreview(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + sep() + append(out) + } + if (err != null) { + sep() + append(err) + } + }.build() +} + +private fun shellBody(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return buildString { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + if (isNotEmpty()) append("\n\n") + append(out) + } + if (err != null) { + if (isNotEmpty()) append("\n\n") + append(err) + } + } +} + +private fun plainPreview(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (out.isNotBlank()) append(out) + if (err != null) { + sep() + append(err) + } + }.build() +} + +internal fun plainBody(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") +} + +internal fun canExpand(tool: Tool): Boolean { + if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() + return output(tool).isNotBlank() || !tool.error.isNullOrBlank() +} + +private fun toolTitle(tool: Tool): String = + tool.title?.takeIf { it.isNotBlank() } + ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } + +private fun toolSubtitle(tool: Tool): String { + val base = listOf("description", "query", "url", "filePath", "path", "name") + .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } + .firstOrNull() + val args = listOf("pattern", "include", "offset", "limit") + .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } + return listOfNotNull(base).plus(args).joinToString(" ") +} + +internal fun tail(path: String): String { + val value = path.trimEnd('/', '\\') + val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) + if (index < 0) return value + return value.substring(index + 1) +} + +private class Preview { + private val text = StringBuilder() + private var cut = false + + fun append(value: String): Preview { + if (cut) return this + val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length + if (value.length <= rem) { + text.append(value) + return this + } + if (rem > 0) text.append(value, 0, rem) + cut = true + return this + } + + fun sep(): Preview { + if (text.isNotEmpty()) append("\n\n") + return this + } + + fun build(): String { + if (!cut) return text.toString() + if (text.isNotEmpty()) text.append("\n\n") + text.append(KiloBundle.message("session.part.tool.truncated")) + return text.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt new file mode 100644 index 00000000000..97324bccd2b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -0,0 +1,185 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +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.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders non-read tool calls with VS Code-inspired rows/cards. */ +class ToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + private var disposed = false + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val was = item.name + item = content + var changed = false + if (was != content.name || !canExpand(content)) changed = collapse() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun commandText(): String = command(item) + @RequiresEdt + fun outputText(): String = output(item) + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun previewText(): String = parts.content?.text ?: preview(item) + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt + internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt + internal fun titleFont() = parts.title.font + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun stateFont() = parts.state.font + @RequiresEdt + internal fun bodyEditable() = parts.content?.editable ?: false + @RequiresEdt + internal fun bodyCaretVisible() = parts.content?.caretVisible ?: false + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = syncLabels() || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun syncLabels(): Boolean { + var changed = false + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + return changed + } + + private fun syncBody(): Boolean { + var changed = false + val body = parts.content ?: return false + val value = preview(item) + if (body.text != value) { + body.text = value + changed = true + } + if (body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun applyBodyStyle(): Boolean { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { + registered = true + body.register(selection, this) + } + return body.applyStyle(style) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val body = parts.content ?: return 0 + return body.lineHeight() * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt index bc8385e96b4..e043225c3ce 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt @@ -51,11 +51,19 @@ open class Stack( private val mgr: Layout get() = getLayout() as Layout + internal fun fit(): Stack { + mgr.fit = true + revalidate() + return this + } + private class Layout( private val axis: StackAxis, private val gap: Int, ) : LayoutManager2 { + var fit = false + private val entries = mutableListOf() fun gap(size: Int) { @@ -83,6 +91,10 @@ open class Stack( val ins = parent.insets val w = maxOf(0, parent.width - ins.left - ins.right) val h = maxOf(0, parent.height - ins.top - ins.bottom) + if (axis == StackAxis.HORIZONTAL && fit) { + fit(parent, ins.left, ins.top, w, h) + return + } var x = ins.left var y = ins.top var seen = false @@ -131,6 +143,48 @@ open class Stack( } } + private fun fit(parent: Container, left: Int, top: Int, w: Int, h: Int) { + val items = children(parent, h) + var x = left + var rest = w + items.forEach { item -> + val gap = minOf(item.gap, rest) + x += gap + rest -= gap + val width = minOf(item.width, rest) + item.comp.setBounds(x, top, width, h) + x += width + rest -= width + } + } + + private fun children(parent: Container, h: Int): List { + val items = mutableListOf() + var seen = false + var ready = false + var pending: Int? = null + for (entry in entries) { + when (entry) { + is Entry.Gap -> if (ready) pending = safe(pending ?: 0, entry.size) + is Entry.Child -> { + val space = pending + pending = null + ready = false + if (entry.comp.isVisible) { + entry.comp.setSize(entry.comp.width.coerceAtLeast(1), h) + val pref = entry.comp.preferredSize + val min = entry.comp.minimumSize + val max = entry.comp.maximumSize + items.add(Item(entry.comp, if (seen) space ?: gap else 0, bound(pref.width, min.width, max.width))) + seen = true + ready = true + } + } + } + } + return items + } + override fun minimumLayoutSize(parent: Container) = size(parent, Size.MIN) override fun preferredLayoutSize(parent: Container) = size(parent, Size.PREF) override fun maximumLayoutSize(target: Container) = size(target, Size.MAX) @@ -204,6 +258,8 @@ open class Stack( data class Child(val comp: Component) : Entry data class Gap(val size: Int) : Entry } + + private data class Item(val comp: Component, val gap: Int, val width: Int) } private enum class Size { MIN, PREF, MAX } @@ -211,6 +267,7 @@ open class Stack( companion object { fun vertical(gap: Int = 0) = Stack(StackAxis.VERTICAL, gap) fun horizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap) + fun fitHorizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap).fit() fun verticalFiller(size: Int): Component = filler(StackAxis.VERTICAL, size) fun horizontalFiller(size: Int): Component = filler(StackAxis.HORIZONTAL, size) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index b5579cdd4fe..cd03b91b5c1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -27,6 +27,7 @@ import org.commonmark.node.Document import org.commonmark.node.FencedCodeBlock import org.commonmark.node.IndentedCodeBlock import org.commonmark.node.Node +import org.commonmark.node.ThematicBreak import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import java.awt.Color @@ -498,6 +499,9 @@ internal class MdViewHybrid( val field = runCatching { CodeField(file, opts, text).also { ed -> ed.setDisposedWith(disposable) + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } selection?.register(ed, disposable) } }.getOrElse { err -> @@ -682,8 +686,14 @@ internal class MdViewHybrid( fun flush() { if (md.isEmpty()) return val doc = parser.parse(md.toString()) - html.append(renderer.render(doc)) - blocks.addAll(collect(doc)) + val descs = collect(doc) + blocks.addAll(descs) + for (desc in descs) { + when (desc) { + is Desc.Html -> html.append(desc.body) + is Desc.Code -> html.append(codeHtml(desc.text)) + } + } md.clear() } @@ -896,27 +906,39 @@ internal class MdViewHybrid( private inner class Visitor : AbstractVisitor() { val blocks = mutableListOf() + private val run = StringBuilder() override fun visit(document: Document) { visitChildren(document) + flush() } override fun visit(code: FencedCodeBlock) { + flush() blocks.add(Desc.Code(code.literal, file(code.info))) } override fun visit(code: IndentedCodeBlock) { + flush() blocks.add(Desc.Code(code.literal, file(null))) } + private fun flush() { + if (run.isEmpty()) return + blocks.add(Desc.Html(run.toString())) + run.clear() + } + public override fun visitChildren(parent: Node) { var child = parent.firstChild while (child != null) { val next = child.next - if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) - if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) { - blocks.add(Desc.Html(renderer.render(child))) + if (child is ThematicBreak) { + child = next + continue } + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) child = next } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg new file mode 100644 index 00000000000..304a93ea9c5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg new file mode 100644 index 00000000000..894515662bf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg new file mode 100644 index 00000000000..b76605a472d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg new file mode 100644 index 00000000000..95762858b59 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg new file mode 100644 index 00000000000..c15ecbd0e45 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg new file mode 100644 index 00000000000..bcce4c17aec --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg new file mode 100644 index 00000000000..11b4cac00b4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg new file mode 100644 index 00000000000..3997b5ec2ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg new file mode 100644 index 00000000000..b916dec2a8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg new file mode 100644 index 00000000000..9d727eb37c2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg new file mode 100644 index 00000000000..57c07f8de87 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg new file mode 100644 index 00000000000..0db8b8f5c86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg new file mode 100644 index 00000000000..c4f66e5533a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg new file mode 100644 index 00000000000..929005b5aaf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg new file mode 100644 index 00000000000..456560f6327 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg new file mode 100644 index 00000000000..947b66ac25c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg new file mode 100644 index 00000000000..554b8656b03 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg new file mode 100644 index 00000000000..ddfe6242464 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg new file mode 100644 index 00000000000..5a27e30208e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg new file mode 100644 index 00000000000..44f84beaa6d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg new file mode 100644 index 00000000000..45d7231e5e7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg new file mode 100644 index 00000000000..14983a084f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg new file mode 100644 index 00000000000..1f891fb11d4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg new file mode 100644 index 00000000000..769102d69ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg new file mode 100644 index 00000000000..3ab3bf728cd --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg new file mode 100644 index 00000000000..a1eff436992 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg new file mode 100644 index 00000000000..cee92d0c0b2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg new file mode 100644 index 00000000000..4eb477f0e37 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg new file mode 100644 index 00000000000..17a37afba86 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg new file mode 100644 index 00000000000..6eaae56d332 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg new file mode 100644 index 00000000000..802ac7d8554 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg new file mode 100644 index 00000000000..d9f9e992c55 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg new file mode 100644 index 00000000000..b26bac3f715 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg new file mode 100644 index 00000000000..80ac013dd3a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg @@ -0,0 +1,4 @@ + + + + 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 ff2594f38fa..9141c1b9a82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -88,6 +88,8 @@ session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.pending=Pending session.part.tool.read=Read +session.part.tool.glob=Glob +session.part.tool.search=Search session.part.tool.running=Running session.part.tool.shell=Shell session.part.tool.truncated=Output truncated in preview. Full output remains in session data. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index a5c6fbcacd3..6aea6cecff2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -1,23 +1,33 @@ package ai.kilocode.client.session import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.ToolRefDto import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.plugin.KiloBundle import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBRadioButton import com.intellij.util.ui.JBUI import java.awt.Container +import java.awt.Point import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.Scrollable +import javax.swing.SwingConstants import javax.swing.JTextArea +import javax.swing.SwingUtilities import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") @@ -43,7 +53,7 @@ class SessionScrollTest : SessionUiTestBase() { if (bottom(bar) <= threshold) { fillTranscript(24, start = 24) } - setValue(bar, bottom(bar) - threshold + 1) + setValuePassive(bar, bottom(bar) - threshold + 1) emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) drainScroll() @@ -51,6 +61,49 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(bar) } + fun `test viewport driven scroll can move away from stale saved position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + val target = (value + JBUI.scale(96)).coerceAtMost(bottom(bar) - 1) + assertTrue("value=$value target=$target bottom=${bottom(bar)}", target > value) + + (scrollComponent() as JBScrollPane).viewport.viewPosition = Point(0, target) + drainScroll() + + assertEquals(target, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test user scroll upward near bottom disables tail follow`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val threshold = JBUI.scale(32) + assertTrue("bottom=${bottom(bar)} threshold=$threshold", bottom(bar) > threshold * 2) + val id = "near_bottom_user_tail" + val pid = "near_bottom_user_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setBottom(bar) + setValue(bar, bottom(bar) - threshold + 1) + val value = bar.value + assertFalse(ui.scroll.following()) + + repeat(240) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n"), flush = false) + } + forceFlush() + drainScroll() + + assertTrue("value=$value actual=${bar.value}", bar.value >= value) + assertFalse(ui.scroll.following()) + } + fun `test session update preserves position outside bottom threshold`() { showMessages() fillTranscript(24) @@ -161,6 +214,22 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test physical mouse wheel uses accelerated transcript unit distance`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + setValue(bar, 0) + drainScroll() + val amount = 3 + val expected = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT * amount) + assertTrue("bottom=${bottom(bar)} expected=$expected", bottom(bar) >= expected * 2) + + val view = scrollView() as Scrollable + val unit = view.getScrollableUnitIncrement(scrollComponent().visibleRect, SwingConstants.VERTICAL, 1) + + assertEquals(expected, unit * amount) + } + fun `test part delta follows bottom after height growth`() { showMessages() fillTranscript(24) @@ -181,6 +250,55 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test user scrolling to bottom during massive stream resumes following`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val id = "stream_massive_resume" + val pid = "stream_massive_resume_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + assertFalse(ui.scroll.following()) + assertTrue(jumpButton().isVisible) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + layout() + setBottom(bar) + drainScroll() + setBottom(bar) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + } + fun `test part delta preserves middle scroll position`() { showMessages() fillTranscript(24) @@ -201,6 +319,51 @@ class SessionScrollTest : SessionUiTestBase() { assertEquals(value, bar.value) } + fun `test expanding tool at bottom preserves clicked header position`() { + val mid = "tool_expand_bottom" + val pid = "tool_expand_bottom_part" + rpc.history.addAll(history(23) + toolHistory(mid, pid) + historyRange(1, start = 23)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + setBottom(bar) + drainScroll() + val view = toolView(mid, pid) + assertFalse(view.bodyVisible()) + val y = visibleY(view) + val value = bar.value + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertEquals(value, bar.value) + } + + fun `test expanding tool in middle preserves clicked header position`() { + val mid = "tool_expand_middle" + val pid = "tool_expand_middle_part" + rpc.history.addAll(history(12) + toolHistory(mid, pid) + historyRange(12, start = 12)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + val view = toolView(mid, pid) + val top = SwingUtilities.convertPoint(view, Point(0, 0), scrollView()).y + setValue(bar, top - 80) + drainScroll() + val y = visibleY(view) + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertTrue(jumpButton().isVisible) + } + fun `test long prompt message follows when transcript is at bottom`() { showMessages() fillTranscript(24) @@ -389,6 +552,56 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(button.isVisible) } + fun `test scroll button resumes following during massive stream`() { + showMessages() + fillTranscript(48) + val button = jumpButton() + val bar = scrollBar() + val id = "stream_massive_button" + val pid = "stream_massive_button_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertTrue(button.isVisible) + assertFalse(ui.scroll.following()) + + click(button) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + } + fun `test scroll button remains hidden outside transcript body`() { val button = jumpButton() @@ -476,11 +689,12 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(scrollBar()) } - fun `test scroll owns the session viewport`() { + fun `test scroll owns the session viewport without overlapping content`() { settle() assertSame(scrollComponent(), scrollView()?.parent?.parent) assertFalse(scrollView() is SessionMessageListPanel) + assertFalse((scrollComponent() as JBScrollPane).isOverlappingScrollBar) } // ------ question/login-required autoscroll ------ @@ -853,6 +1067,15 @@ class SessionScrollTest : SessionUiTestBase() { private inline fun option(label: String): T where T : AbstractButton = findAll(ui).first { it.actionCommand == label } + private fun toolView(mid: String, pid: String): ToolView { + val messages = find(ui) + return messages.findMessage(mid)?.part(pid) as? ToolView + ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") + } + + private fun visibleY(component: JComponent): Int = + SwingUtilities.convertPoint(component, Point(0, 0), scrollComponent()).y + private inline fun findAll(root: Container = ui): List = findAll(root, T::class.java) private fun findAll(root: Container, cls: Class): List { @@ -949,4 +1172,27 @@ class SessionScrollTest : SessionUiTestBase() { ), tool = ToolRefDto("msg1", "call1"), ) + + private fun toolPart(id: String, mid: String) = PartDto( + id = id, + sessionID = "ses_test", + messageID = mid, + type = "tool", + tool = "bash", + callID = "call_$id", + state = "completed", + title = "print output", + output = "output line\n".repeat(160), + ) + + private fun toolHistory(mid: String, pid: String) = MessageWithPartsDto( + message(mid).copy(role = "assistant"), + listOf(toolPart(pid, mid)), + ) + + private fun historyRange(count: Int, start: Int) = List(count) { offset -> + val i = start + offset + val id = "hist_range_$i" + MessageWithPartsDto(message(id), listOf(part("hist_range_part_$i", id, "text", text(i)))) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index e77fb3d6119..4fe55cf5d26 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -73,6 +74,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun tearDown() { try { + Disposer.dispose(ui) scope.cancel() } finally { super.tearDown() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 09f1d288f38..7974709b08d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -4,9 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -45,6 +48,15 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Thinking\u2026", panel.labelText()) } + fun `test panel uses transcript row padding`() { + val ins = panel.insets + + assertEquals(UiStyle.Gap.sm(), ins.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.bottom) + assertEquals(0, ins.right) + } + fun `test panel hides on Idle`() { model.setState(SessionState.Busy("Thinking\u2026")) model.setState(SessionState.Idle) 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 7b13b4a5f7d..8a9ae5ccd20 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 @@ -9,13 +9,14 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TextView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -25,8 +26,13 @@ import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component import java.awt.Container +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JPanel +import javax.swing.border.Border /** * Tests for [SessionMessageListPanel] — structural and index integrity. @@ -537,6 +543,44 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf(".kilo/plans/x.md"), opened) } + fun `test entering a second hoverable part clears stale first hover`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "question", "call1", state = "completed", + input = mapOf("questions" to """[{"question":"First?"}]"""), + metadata = mapOf("answers" to """[["Yes"]]"""), + ), + ) + model.updateContent( + "a1", + toolPart( + "tp2", "a1", "question", "call2", state = "completed", + input = mapOf("questions" to """[{"question":"Second?"}]"""), + metadata = mapOf("answers" to """[["No"]]"""), + ), + ) + val first = panel.findMessage("a1")!!.part("tp1") as QuestionResultView + val second = panel.findMessage("a1")!!.part("tp2") as QuestionResultView + val firstRoot = root(first) + val secondRoot = root(second) + + first.toggle() + second.toggle() + + enter(header(first)) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(first).background.rgb) + assertLine(firstRoot.border) + + enter(header(second)) + + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header(first).background.rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(second).background.rgb) + assertLine(firstRoot.border) + assertLine(secondRoot.border) + } + // ------ helpers ------ private fun panelWithPrompts(): SessionMessageListPanel { @@ -610,4 +654,34 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state, input = input, metadata = metadata, todos = todos, ) + + private fun root(view: QuestionResultView) = view.components[0] as JPanel + + private fun header(view: QuestionResultView) = root(view).components[0] as JPanel + + private fun enter(component: Component) { + component.dispatchEvent(MouseEvent( + component, + MouseEvent.MOUSE_ENTERED, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index 0c64f021c9f..a266bc91432 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PartDto import com.intellij.ide.CopyProvider @@ -47,7 +47,7 @@ class SessionSelectionCopyTest : SessionUiTestBase() { select(two, "bravo") copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) - assertNull(one.selectedText) + assertTrue(one.selectedText.isNullOrEmpty()) assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 4d0177f5666..880899b007e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -83,7 +83,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "bash", "running")) model.updateContent("a1", toolPart("t1", "a1", "bash", "completed")) - val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.ToolView + val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.tool.ToolView assertFalse(tv.labelText().contains("Running")) } @@ -92,7 +92,23 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "read", "completed")) val tv = panel.findMessage("a1")!!.part("t1") - assertTrue(tv is ai.kilocode.client.session.views.ReadToolView) + assertTrue(tv is ai.kilocode.client.session.views.tool.ReadToolView) + } + + fun `test glob tool renders as GlobToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "glob", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.GlobToolView) + } + + fun `test grep tool renders as SearchToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "grep", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.SearchToolView) } // ------ multiple turns update correctly ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt index ce541a5e719..4aaa851181c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -205,8 +205,8 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { val prof = profile(email = "user@example.com") show(snap(prof)) edt { - assertEquals(SessionUiStyle.View.sessionViewBackground(), panel.panelBackground()) - assertEquals(SessionUiStyle.View.sessionViewOutline(), panel.panelBorderColor()) + assertEquals(SessionUiStyle.AccountPopup.bgColor(), panel.panelBackground()) + assertEquals(SessionUiStyle.AccountPopup.outlineColor(), panel.panelBorderColor()) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt new file mode 100644 index 00000000000..67c69d9bf8a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -0,0 +1,155 @@ +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.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class GlobToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test header renders title directory and pattern rows`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Glob")) + assertEquals(listOf("/repo/src", "pattern=**/*.kt"), view.targetTexts()) + assertTrue(view.targetVisible(1)) + } + + fun `test pattern row hides when pattern is absent`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src") + }) + + assertEquals(listOf("/repo/src"), view.targetTexts()) + assertFalse(view.targetVisible(1)) + } + + fun `test repo path displays relative directory`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test repo root directory is hidden`() { + val exact = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "**/*.kt") + }, repo = "/repo") + val dot = GlobToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=**/*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=**/*.kt"), dot.targetTexts()) + assertFalse(exact.targetVisible(1)) + assertFalse(dot.targetVisible(1)) + } + + fun `test outside repo directory stays absolute`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test target labels use regular font`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + + fun `test completed glob starts collapsed and expands output`() { + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + } + + fun `test glob body is lazy and reused`() { + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt" })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + val editor = view.bodyEditor() + assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps glob body uncreated`() { + val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt" }) + + view.update(tool().also { it.output = "/repo/src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("/repo/src/B.kt", view.bodyText()) + } + + fun `test view factory routes glob to glob tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is GlobToolView) + } + + fun `test should replace when glob renderer changes`() { + val glob = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob), read)) + assertFalse(ViewFactory.shouldReplace(GlobToolView(glob), glob)) + } + + private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: GlobToolView): GlobToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt index 60c65070a37..5f651adcb81 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -63,7 +63,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() { val view = LoginRequiredView(openProfile = {}, dismiss = {}) view.show("Sign in required.") val btn = view.openProfileButton() - assertEquals(SessionUiStyle.View.surface(), btn.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), btn.background) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index cc7a2811cf2..93a3d2c173a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.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.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 1d2c8a8e705..5c4bb2947a9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.views.tool.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Component @@ -130,7 +131,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } - fun `test hover border differs from header fill`() { + fun `test hover only changes header background`() { val view = QuestionResultView(completedTool( input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), @@ -138,12 +139,18 @@ class QuestionResultViewTest : BasePlatformTestCase() { val root = view.node(0) val header = root.node(0) - enter(header) + assertEquals(0, paint(root.border).alpha) + view.toggle() + val body = root.node(1) + + view.setHovered(true) - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(root.border).rgb) - assertNotSameColor(SessionUiStyle.View.headerHover(), paint(root.border)) - exit(header) - assertEquals(SessionUiStyle.View.line().rgb, paint(root.border).rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header.background.rgb) + assertLine(root.border) + assertEquals(SessionUiStyle.View.Outline.brightColor().rgb, paint(body.border).rgb) + view.setHovered(false) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header.background.rgb) + assertLine(root.border) } // ------ view factory routing ------ @@ -290,7 +297,17 @@ class QuestionResultViewTest : BasePlatformTestCase() { return Color(image.getRGB(0, 0), true) } - private fun assertNotSameColor(left: Color, right: Color) { - assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 6efd3d240c1..9305d3114a8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -5,8 +5,10 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -15,11 +17,13 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import java.awt.BorderLayout import java.awt.Component import java.awt.Container import kotlin.math.abs import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -134,6 +138,16 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(findAll(view).none { it.text == "1 of 1 questions" && it.isVisible }) } + fun `test single question uses roomy card spacing`() { + view.show(singleSelectQuestion("q_single_spacing")) + + val card = card() + val ins = card.border.getBorderInsets(card) + + assertEquals(UiStyle.Gap.xl(), ins.top) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test single question submit sends selected answer`() { view.show(singleSelectQuestion("req_2")) @@ -294,6 +308,23 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers) } + fun `test multi question progress header has top padding`() { + view.show(twoItemQuestion("q_progress_padding")) + + val card = card() + val outer = card.border.getBorderInsets(card) + val summary = findAll(view).first { it.text == "1 of 2 questions" } + val panel = summary.parent as JComponent + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.sm(), outer.top) + assertEquals(0, ins.top) + assertEquals(0, ins.left) + assertEquals(UiStyle.Gap.sm(), ins.bottom) + assertEquals(0, ins.right) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test multi question uses review before submit`() { view.show(twoItemQuestion("q_review")) @@ -468,8 +499,8 @@ class QuestionViewTest : BasePlatformTestCase() { val dismiss = button(view, "Dismiss") val submit = button(view, "Submit") - assertEquals(SessionUiStyle.View.surface(), dismiss.background) - assertEquals(SessionUiStyle.View.surface(), submit.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), dismiss.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), submit.background) } fun `test review submit and back buttons have correct primary state on review page`() { @@ -929,6 +960,13 @@ class QuestionViewTest : BasePlatformTestCase() { private fun text(root: Container, value: String): JBTextArea = findAll(root).first { it.text == value } + private fun card(): BaseQuestionView = findAll(view).distinct().single() + + private fun spacer(card: BaseQuestionView): Component { + val north = (card.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container + return north.components.last() + } + private fun layout(root: Container, width: Int = 400) { root.setSize(width, root.preferredSize.height) layoutTree(root) 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 a1d27fab1bb..f04fd062a3e 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 @@ -4,6 +4,9 @@ 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.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -85,6 +88,9 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) assertFalse(view.bodyVisible()) assertEquals("file contents", view.bodyText()) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyWrap()) + assertNull(view.bodyEditor()) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) view.toggle() @@ -95,8 +101,8 @@ class ReadToolViewTest : BasePlatformTestCase() { fun `test view factory routes read kind tools to read tool view`() { assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is SearchToolView) + assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is GlobToolView) } fun `test canRender matches read kind tools only`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt new file mode 100644 index 00000000000..d9f1ac10f19 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Reasoning +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import java.awt.Container +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class ReasoningViewStressTest : BasePlatformTestCase() { + + fun `test streaming reasoning retains markdown body and disposes editors`() { + val base = EditorFactory.getInstance().allEditors.size + val view = ReasoningView(reasoning("r1", done = false, text = "intro\n\n```kotlin\n")) + val component = view.md.component + val scroll = scrolls(view).first() + val editor = editors(view).single() + val count = panel(view).componentCount + editor.getEditor(true) + + repeat(150) { i -> view.appendDelta("val x$i = $i\n") } + + assertSame(component, view.md.component) + assertSame(scroll, scrolls(view).first()) + assertSame(editor, editors(view).single()) + assertEquals(1, editors(view).size) + assertTrue(htmls(view).size <= 1) + assertEquals(count, panel(view).componentCount) + + view.update(reasoning("r1", done = true, text = view.markdown() + "```")) + assertTrue(view.bodyVisible()) + Disposer.dispose(view) + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun reasoning(id: String, done: Boolean, text: String) = Reasoning(id).also { + it.done = done + it.content.append(text) + } + + private fun panel(view: ReasoningView): JPanel = view.md.component as JPanel + + private fun scrolls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun htmls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun editors(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun descendants(root: Container): List = root.components.flatMap { child -> + listOf(child) + ((child as? Container)?.let(::descendants) ?: emptyList()) + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 2019e30a4e9..c09062ec808 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 @@ -2,8 +2,15 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Reasoning 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 com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container +import javax.swing.JPanel import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -33,15 +40,16 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.bodyCreated()) } - fun `test streaming reasoning is collapsed by default`() { + fun `test streaming reasoning is expanded by default`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) assertTrue(view.hasToggle()) + assertTrue(view.bodyVisible()) } fun `test update to done preserves collapsed reasoning`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -49,6 +57,29 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) } + fun `test live reasoning stays expanded when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertTrue(view.bodyCreated()) + } + + fun `test manually expanded finished reasoning stays open on update`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) + + view.toggle() + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("one\ntwo\nthree", view.markdown()) + } + fun `test toggle opens and closes reasoning`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -59,7 +90,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test collapsed reasoning stays collapsed on update`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) assertFalse(view.isExpanded()) @@ -72,22 +103,24 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("ab", view.markdown()) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) } - fun `test blank reasoning stays collapsed when delta arrives`() { + fun `test blank streaming reasoning opens when delta arrives`() { val view = ReasoningView(reasoning("p1", done = false, text = "")) + assertFalse(view.isVisible) view.appendDelta("b") assertEquals("b", view.markdown()) - assertFalse(view.bodyCreated()) - assertFalse(view.bodyVisible()) + assertTrue(view.isVisible) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyVisible()) assertTrue(view.hasToggle()) } - fun `test collapsed append keeps lazy reasoning body uncreated`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed append keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) view.appendDelta("b") @@ -96,10 +129,10 @@ class ReasoningViewTest : BasePlatformTestCase() { assertFalse(view.bodyVisible()) } - fun `test collapsed update keeps lazy reasoning body uncreated`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed update keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) - view.update(reasoning("p1", done = false, text = "abc")) + view.update(reasoning("p1", done = true, text = "abc")) assertEquals("abc", view.markdown()) assertFalse(view.bodyCreated()) @@ -107,7 +140,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test reasoning creates lazy markdown body once`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one")) + val view = ReasoningView(reasoning("p1", done = true, text = "one")) view.toggle() val component = view.md.component @@ -121,6 +154,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test blank reasoning has no toggle`() { val view = ReasoningView(reasoning("p1", done = true, text = "")) + assertFalse(view.isVisible) assertFalse(view.isExpanded()) assertFalse(view.hasToggle()) } @@ -158,10 +192,60 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test expanded reasoning body is capped to five rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) - view.toggle() + val taller = ReasoningView(reasoning("p2", done = false, text = (1..200).joinToString("\n") { "line $it" })) assertEquals(5, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) + assertEquals(view.preferredSize.height, taller.preferredSize.height) + } + + fun `test appended reasoning scrolls nested body to bottom`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + + view.appendDelta("\nline 21\nline 22") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) + } + + fun `test appended reasoning does not yank user scrolled above tail`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..40).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + UIUtil.dispatchAllInvocationEvents() + val scroll = scroll(view) + scroll.verticalScrollBar.value = 0 + + view.appendDelta("\nline 41\nline 42") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, scroll.verticalScrollBar.value) + } + + fun `test reasoning block uses vertical separator`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + + assertEquals(1, view.border!!.getBorderInsets(view).left) + + view.toggle() + + val insets = view.border!!.getBorderInsets(view) + assertEquals(0, insets.top) + assertEquals(1, insets.left) + assertEquals(0, insets.bottom) + assertEquals(0, insets.right) + assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) + } + + fun `test reasoning toggle uses shared right rail`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val row = view.components.single() as JPanel + val insets = row.border.getBorderInsets(row) + + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.right) } fun `test link opens url callback`() { @@ -190,4 +274,15 @@ class ReasoningViewTest : BasePlatformTestCase() { it.done = done it.content.append(text) } + + private fun scroll(component: Component): JBScrollPane { + if (component is JBScrollPane) return component + if (component is Container) { + component.components.forEach { child -> + val scroll = runCatching { scroll(child) }.getOrNull() + if (scroll != null) return scroll + } + } + error("scroll not found") + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt new file mode 100644 index 00000000000..eea06ce279f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -0,0 +1,196 @@ +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.SessionEditorStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Container +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class SearchToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test header renders title pattern and include targets`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "class SearchToolView", "include" to "*.{kt,kts}") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Search")) + assertEquals(listOf("pattern=class SearchToolView", "include=*.{kt,kts}"), view.targetTexts()) + assertTrue(view.targetVisible(0)) + assertTrue(view.targetVisible(1)) + assertFalse(view.targetVisible(2)) + } + + fun `test header includes optional path target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(listOf("/repo/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo path displays relative search target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo root search path is hidden`() { + val exact = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + val dot = SearchToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=TODO", "include=*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=TODO", "include=*.kt"), dot.targetTexts()) + assertTrue(exact.targetVisible(0)) + assertFalse(exact.targetVisible(2)) + assertTrue(dot.targetVisible(0)) + assertFalse(dot.targetVisible(2)) + } + + fun `test outside repo search path stays absolute`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test target labels use plain text for clipping`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "", "include" to "*.kt") + }) + + assertEquals("pattern=", view.targetComponents().first().text) + } + + fun `test target labels use regular font`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + + fun `test completed search starts collapsed and expands output`() { + val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + } + + fun `test search body is lazy and reused`() { + val view = track(SearchToolView(tool().also { it.output = "src/A.kt" })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + val editor = view.bodyEditor() + assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps search body uncreated`() { + val view = SearchToolView(tool().also { it.output = "src/A.kt" }) + + view.update(tool().also { it.output = "src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("src/B.kt", view.bodyText()) + } + + fun `test long targets stay horizontal and do not force header wider`() { + val view = SearchToolView(tool().also { + it.input = mapOf( + "pattern" to "a".repeat(200), + "include" to "**/*.${"b".repeat(200)}.kt", + ) + }) + val header = view.headerComponent() + header.setSize(Dimension(240, header.preferredSize.height)) + + layout(header) + + assertTrue(view.centerComponent().width <= header.width) + val labels = view.targetComponents().filter { it.isVisible } + assertEquals(labels.first().y, labels.last().y) + labels.forEach { + assertTrue(it.width <= view.centerComponent().width) + } + } + + fun `test view factory routes grep to search tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is SearchToolView) + } + + fun `test should replace when search renderer changes`() { + val search = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + val glob = Tool("p2", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(SearchToolView(search), read)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob, selection = null), search)) + assertFalse(ViewFactory.shouldReplace(SearchToolView(search), search)) + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach { layout(it) } + } + + private fun tool() = Tool("p1", "grep", toolKind("grep")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: SearchToolView): SearchToolView { + views.add(view) + return view + } +} 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 new file mode 100644 index 00000000000..3b5171f4f3a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -0,0 +1,71 @@ +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.GlobToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil + +@Suppress("UnstableApiUsage") +class ToolBodyStressTest : BasePlatformTestCase() { + + fun `test expanded tool body editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ToolView(tool(i)) + view.toggle() + view.bodyEditor()?.getEditor(true) + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test expanded search tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val search = SearchToolView(search(i)) + search.toggle() + search.bodyEditor()?.getEditor(true) + Disposer.dispose(search) + + val glob = GlobToolView(glob(i)) + glob.toggle() + glob.bodyEditor()?.getEditor(true) + Disposer.dispose(glob) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun tool(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("command" to "log $index") + it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } + } + + private fun search(index: Int) = Tool("s$index", "grep", toolKind("grep")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "needle$index", "include" to "*.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt: needle$index" } + } + + private fun glob(index: Int) = Tool("g$index", "glob", toolKind("glob")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "**/*$index.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt" } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index 44121d61e17..1de7afe7eeb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -7,14 +7,30 @@ import ai.kilocode.client.session.model.toolKind 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.session.views.tool.ToolView +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.image.BufferedImage +import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.border.Border /** * Tests for [ToolView]. */ @Suppress("UnstableApiUsage") class ToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } // ---- state icons ------ @@ -47,14 +63,14 @@ class ToolViewTest : BasePlatformTestCase() { fun `test title shown instead of name when title is set`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Install deps")) assertTrue(view.labelText().contains("Shell")) } fun `test blank title falls back to tool name`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED; it.title = " " } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) } @@ -64,7 +80,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "origin git@example.com:repo.git" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) assertTrue(view.labelText().contains("View remotes")) @@ -94,12 +110,25 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(base is SecondarySessionPartView) } + fun `test tool outline is drawn only while expanded`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertEquals(0, paint(view.border).alpha) + view.toggle() + assertEquals(SessionUiStyle.View.Outline.color().rgb, paint(view.border).rgb) + view.toggle() + assertEquals(0, paint(view.border).alpha) + } + fun `test bash toggle collapses and expands`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) view.toggle() @@ -113,7 +142,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals("$ git log\n\none\ntwo\nthree\nfour", view.bodyText()) assertTrue(view.hasToggle()) @@ -127,23 +156,24 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.bodyCreated()) view.toggle() - val font = view.bodyFont() + val body = view.bodyEditor() + assertNotNull(body) view.toggle() view.toggle() - assertSame(font, view.bodyFont()) + assertSame(body, view.bodyEditor()) assertTrue(view.bodyVisible()) } fun `test collapsed update keeps lazy tool body uncreated`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.update(tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") @@ -155,10 +185,10 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test collapsed update after first expand reuses tool body text`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.toggle() view.toggle() @@ -177,7 +207,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) @@ -191,7 +221,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("path" to "/tmp", "pattern" to "**/*.kt") it.output = "/tmp/A.kt" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Glob")) assertTrue(view.labelText().contains("/tmp")) @@ -205,18 +235,21 @@ class ToolViewTest : BasePlatformTestCase() { fun `test bash output uses editor font settings`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + view.toggle() - assertEditorFont(view.bodyFont(), style) + assertCodeFont(view.bodyFont(), style) assertFalse(view.bodyEditable()) assertFalse(view.bodyCaretVisible()) - assertTrue(view.bodyWrap()) - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + assertFalse(view.bodyWrap()) + assertNotNull(view.bodyEditor()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) } fun `test tool header uses editor-derived fonts`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) @@ -227,10 +260,13 @@ class ToolViewTest : BasePlatformTestCase() { fun `test applyStyle updates tool fonts in place`() { val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) val style = SessionEditorStyle.create(family = "Courier New", size = 25) + view.toggle() + val editor = view.bodyEditor() view.applyStyle(style) - assertEditorFont(view.bodyFont(), style) + assertSame(editor, view.bodyEditor()) + assertCodeFont(view.bodyFont(), style) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) assertSmallEditorFont(view.subtitleFont(), style) @@ -243,7 +279,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals(1, view.controlCount()) } @@ -253,7 +289,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "log") it.output = (1..40).joinToString("\n") { line -> "line $line" } } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() @@ -268,7 +304,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals("$ log\n\n$out", view.bodyText()) @@ -282,7 +318,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals(out, view.bodyText()) @@ -325,13 +361,32 @@ class ToolViewTest : BasePlatformTestCase() { private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = Tool(id, name, toolKind(name)).also { it.state = state; it.title = title } + private fun track(view: ToolView): ToolView { + views.add(view) + return view + } + private fun assertEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.transcriptFont.name, font.name) assertEquals(style.editorSize, font.size) } + private fun assertCodeFont(font: java.awt.Font, style: SessionEditorStyle) { + assertEquals(style.editorFont.name, font.name) + assertEquals(style.editorSize, font.size) + } + private fun assertSmallEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.smallEditorFont.name, font.name) assertTrue(font.size < style.editorSize) } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index 5536843a8a5..7a03cb52083 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -188,6 +188,75 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("hello world", view.markdown()) } + fun `test consecutive reasoning parts reuse one view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1"), mv.partIds()) + assertSame(mv.part("r1"), mv.part("r2")) + assertEquals("first second", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test delta for aliased reasoning appends to reused view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + val mv = MessageView(message, openFile) + + assertTrue(mv.appendDelta("r2", " third")) + + assertEquals("first second third", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test reasoning alias maps stay bounded across churn`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + + repeat(100) { i -> + mv.upsertPart(reasoning("r${i}a", "first $i ")) + mv.upsertPart(reasoning("r${i}b", "second $i")) + + assertEquals(listOf("r${i}a"), mv.partIds()) + assertSame(mv.part("r${i}a"), mv.part("r${i}b")) + assertEquals(1, aliasSize(mv)) + assertEquals(1, sourceSize(mv)) + assertEquals(1, mv.componentCount) + + mv.removePart("r${i}b") + mv.removePart("r${i}a") + + assertTrue(mv.partIds().isEmpty()) + assertEquals(0, aliasSize(mv)) + assertEquals(0, sourceSize(mv)) + assertEquals(0, mv.componentCount) + } + } + + fun `test text between reasoning parts keeps separate views`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first") + message.parts["t1"] = text("t1", "middle") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1", "t1", "r2"), mv.partIds()) + assertNotSame(mv.part("r1"), mv.part("r2")) + } + + fun `test blank reasoning part is invisible`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "") + message.parts["t1"] = text("t1", "middle") + + val mv = MessageView(message, openFile) + + assertFalse(mv.part("r1")!!.isVisible) + assertTrue(mv.part("t1")!!.isVisible) + } + fun `test appendDelta for unknown part id is noop`() { val mv = MessageView(msg("a1", "assistant"), openFile) // Must not throw @@ -226,7 +295,7 @@ class TurnViewTest : BasePlatformTestCase() { fun `test assistant card parts use shared compact gap`() { val message = msg("a1", "assistant") - val reasoning = Reasoning("r1") + val reasoning = reasoning("r1", "thinking") val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool @@ -263,6 +332,23 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + private fun reasoning(id: String, content: String) = Reasoning(id).also { + it.done = false + it.content.append(content) + } + + private fun text(id: String, content: String) = Text(id).also { it.content.append(content) } + + private fun aliasSize(view: MessageView) = mapSize(view, "aliases") + + private fun sourceSize(view: MessageView) = mapSize(view, "sources") + + private fun mapSize(view: MessageView, name: String): Int { + val field = MessageView::class.java.getDeclaredField(name) + field.isAccessible = true + return (field.get(view) as Map<*, *>).size + } + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { val dirty = mutableListOf() val invalid = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 523087c7a22..bc8e1e9ed18 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -69,21 +69,25 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertNull(content.parent) } - fun `test header hover is subtler than hover outline`() { - assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.hoverLine()) - assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.line()) + fun `test header hover fill differs from outline colors`() { + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.hoverColor()) + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.brightColor()) } - fun `test primary card border follows hover color`() { + fun `test primary card hover only changes header background`() { val view = TestView(content = JLabel("body")) - val row = view.component(0) + val row = view.component(0) as JPanel - enter(row) + assertEquals(0, paint(view.border).alpha) + view.expand() - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(view.border).rgb) - assertNotSameColor(SessionUiStyle.View.headerHover(), paint(view.border)) - exit(row) - assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) + view.setHovered(true) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + assertLine(view.border) + view.setHovered(false) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + assertLine(view.border) } private class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : @@ -122,6 +126,19 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { return Color(image.getRGB(0, 0), true) } + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.color().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } + private fun assertNotSameColor(left: Color, right: Color) { assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 41e4229f45d..8136916ef49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -164,7 +164,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val north = region(panel, BorderLayout.NORTH) as Container val filler = north.components.last() - assertEquals(UiStyle.Gap.md(), filler.preferredSize.height) + assertEquals(UiStyle.Gap.lg(), filler.preferredSize.height) assertEquals(0, filler.preferredSize.width) } } @@ -242,8 +242,8 @@ class BaseQuestionViewTest : BasePlatformTestCase() { BaseQuestionView.Action("a", "A", primary = false) {}, BaseQuestionView.Action("b", "B", primary = true) {}, )) - assertEquals(SessionUiStyle.View.surface(), actionButton(panel, "A").background) - assertEquals(SessionUiStyle.View.surface(), actionButton(panel, "B").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "A").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "B").background) } } @@ -296,6 +296,18 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } + fun `test card top padding uses next spacing step`() { + edt { + val panel = BaseQuestionView() + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.pad(), ins.top) + assertEquals(UiStyle.Gap.pad(), ins.left) + assertEquals(UiStyle.Gap.lg(), ins.bottom) + assertEquals(UiStyle.Gap.pad(), ins.right) + } + } + fun `test action left alone attaches footer west`() { edt { val panel = BaseQuestionView() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt index 1e6c3a28150..6b52bef0020 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -4,12 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -410,7 +410,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = findAll(view) assertTrue( "Expected permission warning icon in header", - labels.any { it.icon == AllIcons.General.Warning }, + labels.any { it.icon == SessionViewIcons.warning }, ) } @@ -434,8 +434,8 @@ class PermissionViewTest : BasePlatformTestCase() { fun `test session question buttons use question surface background`() { view.show(permission()) - assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background) - assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.runButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.denyButtonForTest().background) } // ------ code labels use transcript style ------ @@ -494,7 +494,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = view.codeLabelsForTest() assertFalse("Expected code labels", labels.isEmpty()) - assertEquals(SessionUiStyle.View.headerHover(), labels[0].background) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor(), labels[0].background) } private fun permission() = Permission( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt index 9749a587a94..b3702301c9c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt @@ -39,9 +39,9 @@ class UiStyleTest : BasePlatformTestCase() { fun `test session layout constants provide shared geometry`() { assertTrue(JBUI.scale(SessionUiStyle.SessionLayout.GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.GAP) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING) > 0) assertTrue(SessionUiStyle.View.Tool.BODY_LINES > 0) assertTrue(SessionUiStyle.View.Reasoning.BODY_LINES > 0) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt index ec3b1a96a69..887556bfd34 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt @@ -313,6 +313,39 @@ class StackTest : BasePlatformTestCase() { assertBounds(3, 2, 10, 44, a) } + fun `test fit horizontal preserves preferred widths when there is space`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 20, 50, b) + } + + fun `test fit horizontal allocates tight space from the left`() { + val a = child(pref = 20 x 5) + val b = child(pref = 20 x 7) + val c = child(pref = 20 x 9) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 45, 50) + stack.doLayout() + + assertBounds(0, 0, 20, 50, a) + assertBounds(23, 0, 20, 50, b) + assertBounds(45, 0, 0, 50, c) + } + fun `test vertical measures preferred height after width probe`() { val a = object : JBLabel("x") { override fun getMinimumSize() = Dimension(0, 0) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt new file mode 100644 index 00000000000..e9d40248cf6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt @@ -0,0 +1,180 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import javax.swing.Box +import javax.swing.JPanel + +/** + * Stress + leak coverage for the hybrid markdown renderer. + * + * These tests drive many updates through the public [MdView] API and inspect the real + * Swing component tree to prove that: + * - retained component instances survive heavy streaming, + * - the component tree stays bounded (no per-update growth), + * - editors created for code blocks are released (no leak) after churn + clear. + */ +@Suppress("UnstableApiUsage") +class MdViewHybridStressTest : BasePlatformTestCase() { + private lateinit var view: MdView + private var disposed = false + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + + fun `test streaming a large mixed document token by token stays consistent`() { + val doc = buildString { + append("# Heading\n\n") + append("Intro paragraph with **bold** text.\n\n") + append("- one\n- two\n- three\n\n") + append("```kotlin\nval x = 1\n```\n\n") + append("middle prose paragraph\n\n") + append("```java\nclass A {}\n```\n\n") + append("closing prose") + } + + for (token in doc.chunked(3)) view.append(token) + + assertEquals(doc, view.markdown()) + assertEquals(3, htmls().size) + assertEquals(2, scrolls().size) + assertEquals(4, struts().size) // blocks - 1 + assertEquals(9, panel().componentCount) // 5 blocks + 4 struts = 2*5 - 1 + + val html = view.html() + assertTrue(html.contains("

")) + assertTrue(html.contains("
    ")) + assertTrue(html.contains("class A")) + assertFalse(html.contains(" view.append(" more$i") } + + assertSame(intro, htmls().first()) + assertSame(tail, htmls().last()) + assertSame(editor, editors().single()) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertTrue(view.markdown().contains("more99")) + } + + fun `test repeated same structure set reuses single editor and stays bounded`() { + repeat(150) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + } + val editor = editors().single() + + repeat(50) { i -> view.set("```kotlin\nval y = $i\n```") } + + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, panel().componentCount) + assertEquals("val y = 49", editor.text) + } + + fun `test structural churn releases every editor after clear`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + view.set("```java\nclass A$i {}\n```") + editors().single().getEditor(true) + view.set("plain prose $i") + } + + view.clear() + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().isEmpty()) + assertEquals(0, panel().componentCount) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test streaming code body reuses one editor and keeps html in sync`() { + view.append("```java\n") + val pane = scrolls().single() + val editor = editors().single() + + val body = StringBuilder() + repeat(100) { i -> + val line = "void m$i() {}\n" + body.append(line) + view.append(line) + } + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(body.toString().trimEnd('\n'), editor.text) + assertTrue(view.html().contains("void m0()")) + assertTrue(view.html().contains("void m99()")) + + view.append("```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + } + + fun `test style changes during streaming do not rebuild components`() { + view.append("intro\n\n```kotlin\nval x = 1\n```\n\n") + val intro = htmls().first() + val editor = editors().single() + editor.getEditor(true) + val styled = SessionEditorStyle.create(family = "Courier New", size = 18) + val current = SessionEditorStyle.current() + + repeat(50) { i -> + view.append("line $i ") + view.applyStyle(if (i % 2 == 0) styled else current) + if (i % 5 == 0) view.resetStyles() + } + + assertSame(intro, htmls().first()) + assertSame(editor, editors().single()) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertTrue(view.markdown().contains("line 49")) + } + + private fun panel(): JPanel = view.component as JPanel + + private fun scrolls(): List = panel().components.filterIsInstance() + + private fun htmls(): List = panel().components.filterIsInstance() + + private fun struts(): List = panel().components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 7d49a24661a..104b5ab88d3 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 @@ -14,6 +14,7 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color +import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -130,6 +131,97 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height) } + fun `test consecutive prose blocks coalesce into one html pane`() { + view.set("# Title\n\npara one\n\n- a\n- b") + + val pane = htmls().single() + assertTrue(pane.text.contains("

    ")) + assertTrue(pane.text.contains("

    ")) + assertTrue(pane.text.contains("

      ")) + assertTrue(pane.text.contains("
    • ")) + } + + fun `test code block separates surrounding prose runs`() { + view.set("intro\n\n```kotlin\nval x = 1\n```\n\noutro") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, scrolls().size) + assertTrue(html[0].text.contains("intro")) + assertTrue(html[1].text.contains("outro")) + } + + fun `test indented code block separates prose and renders as editor`() { + view.set("before\n\n code line\n\nafter") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, editors().size) + assertTrue(html[0].text.contains("before")) + assertEquals("code line", editors().single().text) + assertTrue(html[1].text.contains("after")) + } + + fun `test coalesced prose has no inter block struts`() { + view.set("first\n\nsecond\n\n- third") + + assertEquals(1, htmls().size) + assertTrue(struts().isEmpty()) + } + + fun `test thematic break after code block is filtered`() { + view.set("```kotlin\nval x = 1\n```\n\n---\n\n# Next") + + val pane = htmls().single() + + assertEquals(1, scrolls().size) + assertTrue(pane.text.contains("

      ")) + assertFalse(pane.text.contains("")) + assertFalse(pane.text.contains(" = (view.component as JPanel).components.filterIsInstance() + private fun struts(): List = (view.component as JPanel).components.filterIsInstance() + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } private fun type(ext: String): FileType {