fix(jetbrains): bound streaming reasoning resources

This commit is contained in:
kirillk
2026-06-09 10:49:47 -04:00
parent d1fa4506c8
commit bbe6f25e21
14 changed files with 338 additions and 258 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"kilo-code": patch
---
Improve JetBrains reasoning blocks so active reasoning opens while streaming, empty blocks stay hidden, and adjacent reasoning renders as one block.
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.
+1
View File
@@ -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
-104
View File
@@ -1,104 +0,0 @@
# Plan: Repo-Relative Search Tool Paths
## Goal
Update JetBrains search-style tool headers so the path target is displayed relative to the current repo/workspace directory:
- If the path is inside the repo, show the relative path.
- If the path resolves to the repo root (`.` or the repo directory), hide the path target entirely.
- If the path is outside the repo, show the full normalized path.
- Apply this to all current search-style tools: `glob` and `grep`/Search.
- Use IntelliJ path utilities instead of handwritten string prefix/splitting logic.
## Findings
- Search-style target rendering is centralized in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt`:
- `globDirectory(tool)` currently returns raw `tool.input["path"]` or `tool.title`.
- `searchTargets(tool)` currently returns raw `tool.input["path"]`, then `pattern=...`, then `include=...`.
- `GlobToolView` and `SearchToolView` both extend `BaseSearchToolView`, which calls `targets(item)` during base initialization.
- `SessionUi` has the repo/workspace directory as `workspace.directory`, but the view creation path currently does not pass it to tool views.
- In JetBrains split mode, frontend and backend may not share OS path semantics, so local `java.nio.file.Path` interpretation alone can be risky for backend-originated paths.
- IntelliJ provides suitable path helpers in `com.intellij.openapi.util.io`:
- `FileUtil.toSystemIndependentName(...)`
- `FileUtil.toCanonicalPath(..., '/', true)`
- `FileUtil.join(...)`
- `FileUtil.getRelativePath(base, file, '/')`
- `OSAgnosticPathUtil.isAbsolute(...)`
- `OSAgnosticPathUtil.startsWith(...)`
- These helpers are string/path-text utilities and do not require VFS refreshes, file existence checks, RPC, git queries, or network calls.
## EDT Safety Requirements
- All search header synchronization runs on the EDT, so path display formatting must be deterministic, local, and non-blocking.
- Do not resolve the repo root during rendering. Use the already-known `Workspace.directory` captured by `SessionUi` and pass it down as an immutable string.
- Do not call any API that can touch disk, VFS, git, RPC, backend services, or the network from `BaseSearchToolView.sync()` / `targets(...)` / the path formatter.
- Specifically avoid `Files.exists`, `Path.toRealPath`, `File.getCanonicalPath`, `LocalFileSystem.refreshAndFindFileByPath`, `VfsUtil`, `Project.baseDir`, `GitRepositoryManager`, `KiloWorkspaceService`, and any coroutine/RPC call in this path.
- The formatter should only use pure string helpers such as `FileUtil.toSystemIndependentName`, `FileUtil.toCanonicalPath(path, '/', true)`, `FileUtil.getRelativePath(base, file, '/')`, and `OSAgnosticPathUtil` checks.
- The amount of work is bounded to a few short path strings per render/update, so no background dispatch or caching is needed unless implementation profiling later shows otherwise.
## Implementation Steps
1. Add an internal search path formatter in `ToolSupport.kt`.
- Keep it pure and UI-independent, for example `internal fun searchPath(path: String, repo: String?): String`.
- Normalize both `path` and `repo` with `FileUtil.toSystemIndependentName` and `FileUtil.toCanonicalPath(..., '/', true)`.
- Use `OSAgnosticPathUtil.isAbsolute(...)` to decide whether the tool path is absolute.
- Resolve relative tool paths against the normalized repo with `FileUtil.join(...)`, then canonicalize.
- If no repo is available, keep the existing raw display behavior except hide `.`.
- If the resolved target equals the repo root, return `""` so the target row is hidden.
- If the resolved target is under the repo root, return `FileUtil.getRelativePath(repo, target, '/')`.
- If the resolved target is outside the repo, return the full normalized target path.
- Avoid manual prefix checks, separator splitting, or homemade `../` handling.
- Keep this formatter string-only: no `java.nio.file.Files`, VFS, git, project model, service, or RPC access.
2. Update target helper functions in `ToolSupport.kt`.
- Change `globDirectory(tool)` to accept `repo: String?` and format the path/title through `searchPath(...)`.
- Change `searchTargets(tool)` to accept `repo: String?` and format only the `path` element through `searchPath(...)`.
- Keep `pattern=...` and `include=...` unchanged.
- Filter blank formatted paths so root paths disappear and pattern/include shift left as they do today when path is absent.
3. Pass repo context into search tool views.
- Add `repo: String? = null` to `BaseSearchToolView` and store it in the base class.
- Change the abstract target hook to use the base-owned repo, e.g. `targets(tool: Tool, repo: String?): List<String>`.
- This avoids accessing subclass properties from `BaseSearchToolView.init`, which already calls `sync()`.
- Add optional `repo` constructor parameters to `GlobToolView` and `SearchToolView` and pass them to `BaseSearchToolView`.
- Preserve existing defaults so direct tests and call sites that do not know a repo still compile.
- Store the repo string as provided; do not lazily resolve or refresh it from IntelliJ project state inside the view.
4. Propagate `workspace.directory` through the session view creation path.
- Add optional `repo: String? = null` parameters through:
- `SessionMessageListPanel`
- `TurnView`
- `MessageView`
- `ViewFactory.create(...)`
- `ViewFactory.createUser(...)`
- In `SessionUi.buildUi()`, pass `repo = workspace.directory` when constructing `SessionMessageListPanel`.
- In `ViewFactory`, pass the repo only to `GlobToolView` and `SearchToolView`; other tool views keep current behavior.
5. Update tests.
- In `GlobToolViewTest`, add coverage for:
- Absolute path inside repo displays as relative, e.g. `src`.
- `.` and exact repo root hide the path row.
- Absolute path outside repo stays full/normalized.
- In `SearchToolViewTest`, add the same repo-relative/root/outside cases for the `path` target while keeping `pattern` and `include` rows unchanged.
- Keep existing no-repo tests to prove fallback behavior remains stable.
- Update any existing expectations that intentionally pass a repo.
- Prefer portable test paths built from simple normalized roots; do not depend on files actually existing.
6. Add a patch changeset.
- This is user-visible JetBrains UI behavior.
- Add a `.changeset/<slug>.md` entry for `"kilo-code": patch`.
- Suggested wording: `Display JetBrains search tool paths relative to the current repository when possible.`
## Verification
Run from `packages/kilo-jetbrains/`:
1. `./gradlew :frontend:test --tests ai.kilocode.client.session.views.GlobToolViewTest --tests ai.kilocode.client.session.views.SearchToolViewTest`
2. `./gradlew typecheck`
If constructor propagation causes broader frontend compile errors, fix those and rerun the same checks.
## Notes
- No `kilocode_change` markers are needed because this is under `packages/kilo-jetbrains/`, a Kilo-owned package.
- Keep the change scoped to search-style tool header paths. Do not alter read tool filename display or tool body output formatting unless requested separately.
-139
View File
@@ -1,139 +0,0 @@
# Refactor SessionUiStyle View Tokens
## Goal
Refactor `SessionUiStyle.View` so tokens are grouped by semantic meaning and call sites describe what they are styling. At the same time, simplify session borders so the style layer only defines two outline colors plus one border width:
- Bright outline color: prompt/user prompt bubble, prompt input shell, and all question-style views.
- Regular outline color: reasoning and all other session card borders/separators by default.
- Border width: shared one-pixel outline width used by views when constructing their own borders.
## Findings
- `SessionUiStyle.View` currently mixes transcript backgrounds, card layout constants, card surfaces, hover colors, outline colors, border factories, and nested component groups in one object.
- Current `View.line()` is a high-contrast editor-background-derived color and is used for both bright prompt/question borders and regular card/separator borders.
- Current `View.sessionViewOutline()` delegates to `UiStyle.Colors.contentBorder()`, which is the right softer/default outline color.
- Existing border helpers (`sessionView`, `outline`, `topOutline`, `leftOutline`) hide whether a call site wants bright or regular outline styling and mix color decisions with border-shape decisions.
- The main production call-site groups are:
- Transcript backgrounds: `SessionMessageListPanel`, `SessionScroll`, `TextView`.
- Card layout/surfaces/hover: base part views, tool/todo/question result views.
- Bright prompt/question borders: `PromptPanel.PromptShell`, `MessageView` user prompt bubble, `BaseQuestionView`, `QuestionResultView`.
- Default cards: `PrimarySessionPartView`, `SecondarySessionPartView`, `ReasoningView`, tool body panes, todo body panes.
- Popup/content panel styling: `SessionAccountOverlay`.
- Separators: `ConnectionPanel`, `Dock.banner()`, `CompactionView`.
## Proposed API
Refactor `SessionUiStyle.kt` without keeping compatibility aliases for old messy names.
```kotlin
object SessionUiStyle {
object Transcript {
fun bgColor(): Color
}
object View {
object Layout {
const val GAP = 6
const val VERTICAL_PADDING = 8
const val HORIZONTAL_PADDING = 12
const val BODY_EXTRA_HEIGHT = 16
}
object Surface {
fun bgColor(): Color
fun headerBgColor(): Color
fun headerHoverBgColor(): Color
}
object Outline {
fun color(): Color
fun brightColor(): Color
fun hoverColor(): Color
fun width(): Int
}
object Prompt { ... }
object Reasoning { ... }
object Message { ... }
object Code { ... }
object Permission { ... }
object Tool { ... }
}
object AccountPopup {
fun bgColor(): Color
fun outlineColor(): Color
}
}
```
Concrete color mapping:
- `View.Outline.color()` returns the softer regular outline, using `UiStyle.Colors.contentBorder()`.
- `View.Outline.brightColor()` preserves the current bright `line()` behavior, using `UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA)`.
- `View.Outline.hoverColor()` preserves the current hover outline calculation for `Surface.headerHoverBgColor()`.
- `View.Outline.width()` returns `JBUI.scale(1)`.
- `View.Surface.bgColor()` and `View.Surface.headerBgColor()` both use `UiStyle.Colors.editorBackground()`.
- `View.Surface.headerHoverBgColor()` preserves the current `headerHover()` behavior.
- `SessionUiStyle.Transcript.bgColor()` replaces `View.transcript()`.
- `SessionUiStyle.AccountPopup.bgColor()` replaces `View.sessionViewBackground()`.
- `SessionUiStyle.AccountPopup.outlineColor()` replaces `View.sessionViewOutline()`.
Border construction rule:
- `SessionUiStyle` does not expose all-side/top/left border factories.
- Views construct borders locally from `View.Outline.color()` or `View.Outline.brightColor()` plus `View.Outline.width()` according to their layout.
- Examples: all-side cards use `JBUI.Borders.customLine(color, width)`, body separators use `JBUI.Borders.customLine(color, width, 0, 0, 0)`, reasoning uses `JBUI.Borders.customLine(color, 0, width, 0, 0)`, and rounded prompt/question shells paint using the same color and width.
## Implementation Plan
1. Update `SessionUiStyle.kt`.
- Add `Transcript`, `View.Layout`, `View.Surface`, `View.Outline`, and `AccountPopup` groups.
- Move current `SESSION_VIEW_*` constants into `View.Layout`.
- Move current `surface`, `header`, and `headerHover` into `View.Surface` with `*BgColor` names.
- Replace `line` and `hoverLine` with the `View.Outline` color/width API above.
- Remove `sessionView`, `outline`, `topOutline`, and `leftOutline` instead of replacing them with new border factory helpers.
- Remove old methods/constants after call sites are migrated.
2. Update regular session-card call sites to softer outlines.
- `PrimarySessionPartView.syncBorder()` constructs an all-side border from `SessionUiStyle.View.Outline.color()` and `width()`.
- `SecondarySessionPartView.syncBorder()` constructs an all-side border from `SessionUiStyle.View.Outline.color()` and `width()`.
- `ReasoningView.syncBorder()` constructs a left-only border from `SessionUiStyle.View.Outline.color()` and `width()`.
- Tool/todo body separators construct top-only borders from `SessionUiStyle.View.Outline.color()` and `width()`.
- Connection, dock banner, and compaction separators use `SessionUiStyle.View.Outline.color()` and `width()` as appropriate.
3. Update bright prompt/question call sites.
- `PromptPanel.PromptShell.outlineColor()` -> `SessionUiStyle.View.Outline.brightColor()` when not focused.
- `MessageView.paintComponent()` user prompt bubble outline -> `brightColor()`.
- `BaseQuestionView.outlineColor()` -> `brightColor()`.
- `QuestionResultView.syncBorder()` constructs an all-side border from `brightColor()` and `width()` when expanded.
- `QuestionResultView` body separator constructs a top-only border from `brightColor()` and `width()`.
4. Update semantic surface/layout call sites.
- Transcript backgrounds -> `SessionUiStyle.Transcript.bgColor()`.
- Card backgrounds -> `SessionUiStyle.View.Surface.bgColor()`.
- Header backgrounds -> `SessionUiStyle.View.Surface.headerBgColor()`.
- Hover header backgrounds -> `SessionUiStyle.View.Surface.headerHoverBgColor()`.
- Layout constants -> `SessionUiStyle.View.Layout.*`.
- Account popup background/border test helper -> `SessionUiStyle.AccountPopup.*`.
5. Update tests.
- Replace direct assertions against old names with semantic new names.
- Add or adjust assertions so prompt/question borders use `View.Outline.brightColor()`.
- Add or adjust assertions so reasoning/tool/regular expanded card borders use `View.Outline.color()`.
- Keep hover tests asserting only header background changes, using `View.Surface.headerHoverBgColor()` and `headerBgColor()`.
- Finish the pending `QuestionResultViewTest` all-side border assertions and make them check the bright outline.
6. Changeset.
- Add a patch changeset for `@kilocode/kilo-jetbrains` because the visible JetBrains session border contrast changes.
- Suggested release note: `Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders.`
## Verification
Run from `packages/kilo-jetbrains/`:
```sh
./gradlew :frontend:test --tests ai.kilocode.client.session.views.base.AbstractSessionPartViewTest --tests ai.kilocode.client.session.views.QuestionResultViewTest --tests ai.kilocode.client.session.ui.SessionMessageListPanelTest --tests ai.kilocode.client.session.views.ToolViewTest --tests ai.kilocode.client.session.views.base.BaseQuestionViewTest --tests ai.kilocode.client.session.views.question.QuestionViewTest --tests ai.kilocode.client.session.views.permission.PermissionViewTest --tests ai.kilocode.client.session.ui.account.SessionAccountOverlayTest
./gradlew typecheck
```
## Constraints
- This task touches only `packages/kilo-jetbrains/` and a changeset.
- Preserve unrelated worktree changes and do not revert user/agent changes from the previous hover/border work.
- Keep Swing UI mutations on the EDT.
- Do not introduce Compose, JCEF, Kotlin UI DSL, services, RPC, or broad UI rewrites.
- Prefer small, mechanical call-site updates over deeper component refactors.
@@ -53,6 +53,9 @@ class MessageView(
get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default
private val parts = LinkedHashMap<String, PartView>()
// 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<String, String>()
private val sources = LinkedHashMap<String, String>()
private var hidden: ToolCallRef? = null
@@ -15,6 +15,7 @@ import ai.kilocode.client.ui.md.MdViewFactory
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
@@ -40,7 +41,9 @@ class ReasoningView(
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)
@@ -56,6 +59,7 @@ class ReasoningView(
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(
@@ -69,6 +73,7 @@ class ReasoningView(
sync()
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
@@ -78,6 +83,7 @@ class ReasoningView(
return true
}
@RequiresEdt
override fun collapse(): Boolean {
val changed = super.collapse()
if (!changed) return false
@@ -85,10 +91,13 @@ class ReasoningView(
return true
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Reasoning) return
var changed = false
val next = content.content.toString()
val finished = !done && content.done
val follow = tailVisible()
if (done != content.done) {
done = content.done
changed = true
@@ -97,36 +106,50 @@ class ReasoningView(
source = next
if (parts.bodyCreated()) {
md.set(source)
followTail()
followTail(follow)
}
changed = true
}
if (finished) changed = collapse() || changed
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)
followTail()
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
@@ -138,6 +161,7 @@ class ReasoningView(
if (changed) refresh()
}
@RequiresEdt
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
@@ -188,11 +212,12 @@ class ReasoningView(
return changed
}
@RequiresEdt
private fun syncBody() {
val md = md
registerBody(md)
md.set(source)
followTail()
followTail(true)
}
private fun applyBodyStyle(): Boolean {
@@ -216,10 +241,22 @@ class ReasoningView(
JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT)
}
private fun followTail() {
if (!bodyVisible()) return
@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
}
@@ -9,6 +9,7 @@ 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
@@ -39,6 +40,7 @@ abstract class BaseSearchToolView(
sync()
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
@@ -47,6 +49,7 @@ abstract class BaseSearchToolView(
return true
}
@RequiresEdt
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
@@ -54,6 +57,7 @@ abstract class BaseSearchToolView(
return Dimension(size.width, minOf(size.height, height))
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Tool) return
item = content
@@ -62,29 +66,49 @@ abstract class BaseSearchToolView(
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<String> = 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
@@ -9,6 +9,7 @@ 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
@@ -38,6 +39,7 @@ class ReadToolView(
sync()
}
@RequiresEdt
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
@@ -45,6 +47,7 @@ class ReadToolView(
return Dimension(size.width, minOf(size.height, height))
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Tool) return
item = content
@@ -53,28 +56,48 @@ class ReadToolView(
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
@@ -26,13 +26,14 @@ 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.Dimension
import java.awt.Font
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
@@ -65,23 +66,30 @@ class ToolParts(
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
@@ -100,7 +108,9 @@ class ToolBody private constructor(
private val disposable: Disposable?,
) : Disposable {
var text: String
@RequiresEdt
get() = area?.text ?: ed?.text ?: ""
@RequiresEdt
set(value) {
if (text == value) return
area?.text = value
@@ -110,7 +120,9 @@ class ToolBody private constructor(
}
var font: Font
@RequiresEdt
get() = area?.font ?: ed?.font ?: SessionEditorStyle.current().editorFont
@RequiresEdt
set(value) {
area?.font = value
ed?.font = value
@@ -118,7 +130,9 @@ class ToolBody private constructor(
}
var foreground: Color
@RequiresEdt
get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg()
@RequiresEdt
set(value) {
area?.foreground = value
ed?.foreground = value
@@ -129,11 +143,13 @@ class ToolBody private constructor(
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
@@ -143,6 +159,7 @@ class ToolBody private constructor(
return before != font
}
@RequiresEdt
fun register(selection: SessionSelection, parent: Disposable) {
val field = ed
if (field != null) {
@@ -152,6 +169,7 @@ class ToolBody private constructor(
area?.let { selection.register(it, parent) }
}
@RequiresEdt
fun lineHeight(): Int = ed?.getEditor(false)?.lineHeight ?: scroll.viewport.view.getFontMetrics(font).height
override fun dispose() {
@@ -162,15 +180,15 @@ class ToolBody private constructor(
val view = scroll.viewport.view as? JComponent ?: return
val height = height(view)
val width = width(view)
view.preferredSize = Dimension(width, height)
view.minimumSize = Dimension(0, height)
view.maximumSize = Dimension(Int.MAX_VALUE, height)
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 = Dimension(0, pane)
scroll.minimumSize = Dimension(0, pane)
scroll.maximumSize = Dimension(Int.MAX_VALUE, pane)
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 {
@@ -186,6 +204,7 @@ class ToolBody private constructor(
}
companion object {
@RequiresEdt
fun editor(tool: Tool): ToolBody {
val disposable = Disposer.newDisposable("Tool body")
val body = runCatching {
@@ -200,6 +219,7 @@ class ToolBody private constructor(
return body
}
@RequiresEdt
fun text(tool: Tool): ToolBody {
val area = area(tool, true)
val body = ToolBody(area, null, pane(area, false), null)
@@ -275,6 +295,7 @@ private class ToolField(value: String, private var style: SessionEditorStyle) :
private const val SUB_CARD = "sub"
private const val LINK_CARD = "link"
@RequiresEdt
internal fun toolParts(
tool: Tool,
openFile: ((String) -> Unit)? = null,
@@ -318,6 +339,7 @@ internal fun toolParts(
}
}
@RequiresEdt
internal fun searchParts(count: Int): ToolParts {
val glyph = JBLabel()
val title = JBLabel()
@@ -325,7 +347,7 @@ internal fun searchParts(count: Int): ToolParts {
val targets = List(count) {
JBLabel().apply {
foreground = UiStyle.Colors.fg()
minimumSize = Dimension(0, minimumSize.height)
minimumSize = JBUI.size(0, minimumSize.height)
}
}
val link = JBLabel().apply { isVisible = false }
@@ -339,7 +361,7 @@ internal fun searchParts(count: Int): ToolParts {
val target = stack.align(HAlign.TRACK, VAlign.CENTER)
val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
isOpaque = false
minimumSize = Dimension(0, minimumSize.height)
minimumSize = JBUI.size(0, minimumSize.height)
add(title, BorderLayout.WEST)
add(target, BorderLayout.CENTER)
}
@@ -382,6 +404,7 @@ internal fun subtitle(tool: Tool) = when (tool.name) {
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
@@ -389,12 +412,14 @@ internal fun setText(label: JBLabel, text: String): Boolean {
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("<u>${XmlStringUtil.escapeString(text)}</u>")
if (parts.label == text && parts.link.text == value) return false
@@ -403,6 +428,7 @@ internal fun setLinkText(parts: ToolParts, text: String): Boolean {
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)
@@ -411,24 +437,28 @@ internal fun show(parts: ToolParts, link: Boolean): Boolean {
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
@@ -9,6 +9,7 @@ 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
@@ -33,6 +34,7 @@ class ToolView(
sync()
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
@@ -41,6 +43,7 @@ class ToolView(
return true
}
@RequiresEdt
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
@@ -48,6 +51,7 @@ class ToolView(
return Dimension(size.width, minOf(size.height, height))
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Tool) return
val was = item.name
@@ -59,30 +63,51 @@ class ToolView(
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
@@ -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() + "```"))
assertFalse(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<JBScrollPane>()
private fun htmls(view: ReasoningView) = descendants(view).filterIsInstance<JBHtmlPane>()
private fun editors(view: ReasoningView) = descendants(view).filterIsInstance<EditorTextField>()
private fun descendants(root: Container): List<java.awt.Component> = root.components.flatMap { child ->
listOf(child) + ((child as? Container)?.let(::descendants) ?: emptyList())
}
private fun drainEdt() {
UIUtil.dispatchAllInvocationEvents()
}
}
@@ -5,8 +5,11 @@ 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
@@ -54,6 +57,29 @@ class ReasoningViewTest : BasePlatformTestCase() {
assertEquals("one\ntwo\nthree\nfour", view.markdown())
}
fun `test live reasoning collapses 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"))
assertFalse(view.isExpanded())
assertFalse(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"))
@@ -184,6 +210,20 @@ class ReasoningViewTest : BasePlatformTestCase() {
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"))
@@ -234,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")
}
}
@@ -3,6 +3,8 @@ 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
@@ -26,12 +28,43 @@ class ToolBodyStressTest : BasePlatformTestCase() {
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()
}
@@ -211,6 +211,29 @@ class TurnViewTest : BasePlatformTestCase() {
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")
@@ -316,6 +339,16 @@ class TurnViewTest : BasePlatformTestCase() {
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<JComponent>) : RepaintManager() {
val dirty = mutableListOf<JComponent>()
val invalid = mutableListOf<JComponent>()