Merge pull request #11932 from Kilo-Org/massive-fontina

fix(jetbrains): polish transcript and settings interactions
This commit is contained in:
Kirill Kalishev
2026-07-06 10:49:32 -04:00
committed by GitHub
59 changed files with 2429 additions and 715 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix prompt submission in JetBrains IDEs when sending messages with file or git-change mentions.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix unreliable clicks on inline action buttons (Connect, OAuth, Disconnect, Enable) in the JetBrains provider, agent, and MCP settings lists so the whole button is clickable.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a focus outline around the JetBrains prompt input.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show subagent tool activity inline in JetBrains session transcripts.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Fix JetBrains prompt pickers so reasoning effort opens above the button and expanded model details still allow one-click model selection.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Hide the JetBrains editor floating toolbar from the Kilo prompt input.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Balance JetBrains shell command tooltip padding when a horizontal scrollbar is present.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Increase JetBrains todo checklist inner padding.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Use the standard transcript font for JetBrains prompt text and custom question responses.
@@ -74,7 +74,6 @@ import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.ConfigurableWithId
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -369,6 +368,7 @@ class SessionUi(
onEnhance = controller::enhancePrompt,
onMentions = ::mentionParts,
completion = completion,
cs = cs,
)
connection = ConnectionPanel(this, controller)
root.addOverlay(connection) { pane, child ->
@@ -695,9 +695,9 @@ class SessionUi(
spec.available,
)
private fun mentionParts(text: String): List<PromptPartDto> = runBlockingCancellable {
private suspend fun mentionParts(text: String): List<PromptPartDto> {
val names = MentionAction.ALL.mapTo(mutableSetOf()) { it.name }
promptMentionParts(
return promptMentionParts(
text = text,
directory = workspace.directory,
reserved = names,
@@ -147,6 +147,7 @@ class SessionController(
private var creating: CompletableDeferred<String?>? = null
private val childJobs: MutableMap<String, Job> = mutableMapOf()
private val childIds: MutableSet<String> = mutableSetOf()
private val childParts: MutableMap<PartKey, String> = mutableMapOf()
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
private var recentsState: RecentsState = RecentsState.Idle
private var viewState: SessionControllerEvent.ViewChanged? = null
@@ -166,8 +167,6 @@ class SessionController(
private var modelTime: Double? = null
private val snapshots = mutableMapOf<PartKey, String>()
private data class PartKey(val messageId: String, val partId: String)
val ready: Boolean get() = model.isReady()
val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove()
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
@@ -763,12 +762,14 @@ class SessionController(
val session = target.session ?: runCatching { sessions.get(id, directory) }.getOrNull()
val items = sessions.messages(id, directory)
LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" }
val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet()
val discovered = children(items)
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
updateModel {
snapshots.clear()
childParts.clear()
childParts.putAll(discovered)
this@SessionController.model.loadHistory(items)
syncHistoryAgent(items)
if (session != null) this@SessionController.model.setSession(session)
@@ -778,7 +779,7 @@ class SessionController(
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
for (child in discovered) trackChild(child)
for (child in discovered.values.toSet()) trackChild(child)
showSession()
loaded(!model.isEmpty())
}
@@ -811,7 +812,7 @@ class SessionController(
val session = sessions.importCloudSession(id, directory)
val items = sessions.messages(session.id, directory)
LOG.debug { "${ChatLogSummary.sid(session.id)} ${ChatLogSummary.history(items)}" }
val discovered = items.flatMap { it.parts }.mapNotNull { childID(it) }.toSet()
val discovered = children(items)
runEdt {
if (disposed) return@runEdt
ref = SessionRef.Local(session)
@@ -826,8 +827,10 @@ class SessionController(
recoverPending(session.id)
runEdt {
if (disposed) return@runEdt
for (child in discovered) trackChild(child)
subscribeEvents()
childParts.clear()
childParts.putAll(discovered)
for (child in discovered.values.toSet()) trackChild(child)
showSession()
loaded(!model.isEmpty())
}
@@ -894,7 +897,7 @@ class SessionController(
val job = cs.launch {
try {
sessions.events(child, directory).collect { event ->
if (!isChildPermissionEvent(event, child)) return@collect
if (!isChildEvent(event, child)) return@collect
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-event child=$child ${ChatLogSummary.eventBody(event)}" }
updates.enqueue(event)
}
@@ -914,9 +917,32 @@ class SessionController(
assertEdt()
if (!childIds.add(child)) return
subscribeChild(child)
cs.launch { seedChild(child) }
cs.launch { recoverChildPermissions(child) }
}
@RequiresEdt
private fun trackChild(key: PartKey, child: String) {
assertEdt()
childParts[key] = child
trackChild(child)
}
@RequiresEdt
private fun untrackChild(key: PartKey) {
assertEdt()
val child = childParts.remove(key) ?: return
if (child in childParts.values) return
childIds.remove(child)
childJobs.remove(child)?.cancel()
}
@RequiresEdt
private fun untrackChildren(messageId: String) {
assertEdt()
childParts.keys.filter { it.messageId == messageId }.forEach(::untrackChild)
}
@RequiresEdt
private fun cancelSubscriptions() {
assertEdt()
@@ -925,6 +951,7 @@ class SessionController(
childJobs.values.forEach { it.cancel() }
childJobs.clear()
childIds.clear()
childParts.clear()
}
private suspend fun recoverChildPermissions(child: String) {
@@ -939,6 +966,7 @@ class SessionController(
val last = toPermission(permissions.last())
runEdt {
if (disposed) return@runEdt
if (child !in childIds) return@runEdt
// Do not overwrite an existing root or other child AwaitingPermission state
if (model.state is SessionState.AwaitingPermission) return@runEdt
updateModel { model.setState(SessionState.AwaitingPermission(last)) }
@@ -948,6 +976,28 @@ class SessionController(
}
}
private suspend fun seedChild(child: String) {
try {
val items = sessions.messages(child, directory)
runEdt {
if (disposed) return@runEdt
if (child !in childIds) return@runEdt
updateModel {
for (msg in items) {
if (msg.info.role != "assistant") continue
for (part in msg.parts) {
if (part.type == "tool") model.upsertChildTool(child, part, replace = false)
}
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: "pending")} kind=child-history child=$child dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
/** Rehydrate pending permissions/questions and current session status after history load. */
private suspend fun recoverPending(id: String) {
try {
@@ -1026,10 +1076,17 @@ class SessionController(
}
is ChatEventDto.PartUpdated -> {
if (childIds.contains(event.sessionID)) {
if (event.part.type == "tool") model.upsertChildTool(event.sessionID, event.part)
return
}
partType = event.part.type
tool = event.part.tool
val key = PartKey(event.part.messageID, event.part.id)
val prev = content(event.part.messageID, event.part.id)
val child = childID(event.part)
val old = childParts[key]
if (old != null && old != child) untrackChild(key)
model.updateContent(event.part.messageID, event.part)
val next = content(event.part.messageID, event.part.id)
if (next != null && next != prev) {
@@ -1040,7 +1097,7 @@ class SessionController(
if (model.state is SessionState.Busy) {
model.setState(SessionState.Busy(status()))
}
childID(event.part)?.let { child -> trackChild(child) }
if (child != null) trackChild(key, child)
}
is ChatEventDto.PartDelta -> {
@@ -1051,7 +1108,13 @@ class SessionController(
}
is ChatEventDto.PartRemoved -> {
snapshots.remove(PartKey(event.messageID, event.partID))
if (childIds.contains(event.sessionID)) {
model.removeChildTool(event.sessionID, event.partID)
return
}
val key = PartKey(event.messageID, event.partID)
snapshots.remove(key)
untrackChild(key)
model.removeContent(event.messageID, event.partID)
}
@@ -1086,6 +1149,7 @@ class SessionController(
is ChatEventDto.MessageRemoved -> {
snapshots.keys.removeAll { it.messageId == event.messageID }
untrackChildren(event.messageID)
model.removeMessage(event.messageID)
}
@@ -1918,8 +1982,20 @@ private fun childID(part: PartDto): String? {
return part.metadata["sessionId"]
}
/** Returns true when [event] is a permission event for [child] (used by child subscriptions). */
private fun isChildPermissionEvent(event: ChatEventDto, child: String): Boolean = when (event) {
private data class PartKey(val messageId: String, val partId: String)
private fun children(items: List<MessageWithPartsDto>): Map<PartKey, String> = buildMap {
for (msg in items) {
for (part in msg.parts) {
childID(part)?.let { put(PartKey(msg.info.id, part.id), it) }
}
}
}
/** Returns true when [event] should be routed from a child subscription. */
private fun isChildEvent(event: ChatEventDto, child: String): Boolean = when (event) {
is ChatEventDto.PartUpdated -> event.sessionID == child
is ChatEventDto.PartRemoved -> event.sessionID == child
is ChatEventDto.PermissionAsked -> event.sessionID == child
is ChatEventDto.PermissionReplied -> event.sessionID == child
else -> false
@@ -82,6 +82,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
var title: String? = null
var input: Map<String, String> = emptyMap()
var metadata: Map<String, String> = emptyMap()
var childSessionId: String? = null
var childTools: List<Tool> = emptyList()
var output: String? = null
var error: String? = null
var time: PartTimeDto? = null
@@ -47,6 +47,9 @@ class SessionModel {
private val entries = LinkedHashMap<String, Message>()
private val turnEntries = LinkedHashMap<String, Turn>()
private val hiddenText = mutableSetOf<Pair<String, String>>()
private val childRefs = HashMap<String, ChildRef>()
private val childTools = HashMap<String, LinkedHashMap<String, Tool>>()
private val childRemoved = HashMap<String, MutableSet<String>>()
var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
var version: String? = null
@@ -145,7 +148,8 @@ class SessionModel {
@RequiresEdt
fun removeMessage(id: String) {
if (entries.remove(id) == null) return
val msg = entries.remove(id) ?: return
for (part in msg.parts.values) untrackChild(part)
hiddenText.removeAll { it.first == id }
fire(SessionModelEvent.MessageRemoved(id))
regroup()
@@ -156,7 +160,8 @@ class SessionModel {
fun removeContent(messageId: String, contentId: String) {
hiddenText.remove(messageId to contentId)
val msg = entries[messageId] ?: return
if (msg.parts.remove(contentId) == null) return
val old = msg.parts.remove(contentId) ?: return
untrackChild(old)
fire(SessionModelEvent.ContentRemoved(messageId, contentId))
updateHeader()
}
@@ -186,10 +191,41 @@ class SessionModel {
}
val content = fromDto(dto)
msg.parts[dto.id] = content
trackChild(messageId, content)
fire(SessionModelEvent.ContentAdded(messageId, content))
updateHeader()
}
@RequiresEdt
fun upsertChildTool(child: String, dto: PartDto, replace: Boolean = true) {
if (dto.type != "tool") return
val ref = childRefs[child] ?: return
val msg = entries[ref.messageId] ?: return
val parent = msg.parts[ref.partId] as? Tool ?: return
val tool = fromDto(dto) as? Tool ?: return
val tools = childTools.getOrPut(child) { LinkedHashMap() }
if (replace) childRemoved[child]?.remove(dto.id)
if (!replace && childRemoved[child]?.contains(dto.id) == true) return
if (!replace && tools.containsKey(dto.id)) return
tools[dto.id] = tool
parent.childTools = tools.values.toList()
fire(SessionModelEvent.ContentUpdated(ref.messageId, parent))
updateHeader()
}
@RequiresEdt
fun removeChildTool(child: String, partId: String) {
childRemoved.getOrPut(child) { mutableSetOf() }.add(partId)
val ref = childRefs[child] ?: return
val tools = childTools[child] ?: return
if (tools.remove(partId) == null) return
val msg = entries[ref.messageId] ?: return
val parent = msg.parts[ref.partId] as? Tool ?: return
parent.childTools = tools.values.toList()
fire(SessionModelEvent.ContentUpdated(ref.messageId, parent))
updateHeader()
}
@RequiresEdt
fun appendDelta(messageId: String, contentId: String, delta: String) {
val msg = entries[messageId] ?: return
@@ -257,6 +293,9 @@ class SessionModel {
@RequiresEdt
fun loadHistory(history: List<MessageWithPartsDto>) {
entries.clear()
childRefs.clear()
childTools.clear()
childRemoved.clear()
hiddenText.clear()
session = null
state = SessionState.Idle
@@ -274,6 +313,7 @@ class SessionModel {
if (empty(part)) continue
val content = fromDto(part, part.text)
item.parts[content.id] = content
trackChild(msg.info.id, content)
}
entries[msg.info.id] = item
}
@@ -286,6 +326,9 @@ class SessionModel {
fun clear() {
entries.clear()
turnEntries.clear()
childRefs.clear()
childTools.clear()
childRemoved.clear()
hiddenText.clear()
session = null
state = SessionState.Idle
@@ -410,17 +453,25 @@ class SessionModel {
existing.source = dto.source
}
is Tool -> {
val old = existing.childSessionId
existing.kind = toolKind(dto.tool)
existing.state = parseToolState(dto.state)
existing.callId = dto.callID
existing.title = dto.title
existing.input = dto.input
existing.metadata = dto.metadata
existing.childSessionId = childID(existing)
if (old != null && old != existing.childSessionId) {
childRefs.remove(old)
childTools.remove(old)
childRemoved.remove(old)
}
existing.output = dto.output
existing.error = dto.error
existing.time = dto.time
existing.todos = dto.todos
existing.todoView = dto.todoView
trackChild(messageId, existing)
}
is Compaction -> return
is StepFinish -> {
@@ -461,6 +512,7 @@ class SessionModel {
title = dto.title
input = dto.input
metadata = dto.metadata
childSessionId = childID(this)
output = dto.output
error = dto.error
time = dto.time
@@ -481,6 +533,21 @@ class SessionModel {
for (l in listeners) l.onEvent(event)
}
private fun trackChild(messageId: String, content: Content) {
val tool = content as? Tool ?: return
val child = tool.childSessionId ?: return
childRefs[child] = ChildRef(messageId, tool.id)
tool.childTools = childTools[child]?.values?.toList() ?: emptyList()
}
private fun untrackChild(content: Content) {
val tool = content as? Tool ?: return
val child = tool.childSessionId ?: return
childRefs.remove(child)
childTools.remove(child)
childRemoved.remove(child)
}
private fun updateHeader() {
val next = buildHeader()
if (next == header) return
@@ -599,6 +666,13 @@ private fun parseToolState(raw: String?): ToolExecState = when (raw) {
else -> ToolExecState.PENDING
}
private data class ChildRef(val messageId: String, val partId: String)
private fun childID(tool: Tool): String? {
if (tool.name != "task") return null
return tool.metadata["sessionId"]
}
data class AgentItem(
val name: String,
val display: String,
@@ -6,6 +6,7 @@ import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.ListPopup
import com.intellij.openapi.ui.popup.PopupStep
import com.intellij.openapi.ui.popup.PopupShowOptions
import com.intellij.openapi.ui.popup.util.BaseListPopupStep
import com.intellij.util.ui.EmptyIcon
import java.awt.Cursor
@@ -17,7 +18,7 @@ import javax.swing.Icon
* Clickable label-style dropdown picker with a native filled background.
*
* Shows the selected item's display text with a down-arrow. On click,
* opens a list popup below the picker. Disabled (greyed out, not
* opens a list popup above the picker. Disabled (greyed out, not
* clickable) when no items are loaded.
*/
class ReasoningPicker : PickerButton() {
@@ -100,7 +101,7 @@ class ReasoningPicker : PickerButton() {
}
val popup: ListPopup = JBPopupFactory.getInstance().createListPopup(step)
popup.showUnderneathOf(this)
popup.show(PopupShowOptions.aboveComponent(this))
}
private fun icon(item: Item): Icon = if (item.id == selected?.id) checked else empty
@@ -5,6 +5,7 @@ import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.SendPromptContext
import ai.kilocode.client.session.ui.selection.SessionSelection
import com.intellij.ide.actions.UndoRedoAction
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataSink
@@ -12,16 +13,24 @@ import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
import com.intellij.openapi.command.undo.UndoManager
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.fileEditor.impl.text.TextEditorProvider
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.PlainTextLanguage
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.LanguageTextField
import com.intellij.util.textCompletion.TextCompletionProvider
import com.intellij.util.textCompletion.TextCompletionUtil
import com.intellij.util.ui.update.UiNotifyConnector
import java.awt.Component
import java.awt.Container
// The toolbar class is internal; match by name to avoid linking against internal API.
private const val TOOLBAR = "com.intellij.openapi.editor.toolbar.floating.EditorFloatingToolbar"
/**
* A session-scoped [EditorTextField] for plain-text input.
@@ -69,6 +78,10 @@ internal open class SessionEditorTextField(
}
private fun install(editor: Editor) {
(editor as? EditorEx)?.setEmbeddedIntoDialogWrapper(true)
// EditorImpl lazily creates EditorFloatingToolbar with the same first-show hook.
// Settings providers run later, so this callback runs immediately after toolbar creation.
UiNotifyConnector.doWhenFirstShown(editor.component) { hide(editor.component) }
editor.contentComponent.putClientProperty(UndoRedoAction.IGNORE_SWING_UNDO_MANAGER, true)
// Workaround: global $Undo/$Redo can miss the synthetic FileEditor for this embedded
// EditorTextField. Bind the shortcuts locally until the platform data context targets it reliably.
@@ -108,4 +121,19 @@ internal open class SessionEditorTextField(
private fun file(): TextEditor? {
return getEditor(false)?.let(TextEditorProvider.getInstance()::getTextEditor)
}
private fun hide(component: Component): Boolean {
if (component.javaClass.name == TOOLBAR) {
(component as? Disposable)?.let(Disposer::dispose)
component.parent?.remove(component)
return true
}
if (component !is Container) return false
val hidden = component.components.fold(false) { removed, child -> hide(child) || removed }
if (hidden) {
component.revalidate()
component.repaint()
}
return hidden
}
}
@@ -379,11 +379,6 @@ class ModelPicker : PickerButton() {
e.consume()
return
}
if (expanded && e.clickCount < 2) {
list.selectedIndex = row
syncDetails()
return
}
activate(value)
}
})
@@ -54,10 +54,11 @@ import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.keymap.Keymap
import com.intellij.openapi.keymap.KeymapManagerListener
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.IslandsState
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.xml.util.XmlStringUtil
import com.intellij.util.ui.JBDimension
@@ -66,6 +67,12 @@ import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import com.intellij.util.messages.MessageBusConnection
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.BasicStroke
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.Graphics
@@ -79,6 +86,7 @@ import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.geom.Path2D
import java.util.concurrent.Future
import javax.swing.Box
import javax.swing.BoxLayout
@@ -97,9 +105,10 @@ class PromptPanel(
private val onSend: (String, List<PromptPartDto>) -> Unit,
private val onAbort: () -> Unit,
private val onEnhance: (String, (Result<String>) -> Unit) -> Unit,
private val onMentions: (String) -> List<PromptPartDto> = { emptyList() },
private val onMentions: suspend (String) -> List<PromptPartDto> = { emptyList() },
private val completion: KiloPromptCompletionProvider? = null,
private val selection: SessionSelection? = null,
private val cs: CoroutineScope = CoroutineScope(Dispatchers.Default),
) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext, UiDataProvider {
companion object {
@@ -158,7 +167,7 @@ class PromptPanel(
setShowPlaceholderWhenFocused(true)
setOneLineMode(false)
addSettingsProvider { ed ->
style.applyToEditor(ed)
style.applyTranscriptToEditor(ed)
ed.setBorder(JBUI.Borders.empty())
ed.scrollPane.border = JBUI.Borders.empty()
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
@@ -298,6 +307,54 @@ class PromptPanel(
)
}
override fun paintChildren(g: Graphics) {
super.paintChildren(g)
if (!editorFocused()) return
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val line = JBUI.scale(SessionUiStyle.View.Prompt.FOCUS_WIDTH)
val half = line / 2f
val top = half
val left = half
val right = width - half
val bottom = height - half
val arc = if (IslandsState.isEnabled()) {
JBUI.scale(JBUI.getInt("Island.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) / 2f
} else {
0f
}
val radius = arc
.coerceAtMost((right - left) / 2f)
.coerceAtMost(bottom - top)
.coerceAtLeast(0f)
val path = Path2D.Float().apply {
moveTo(left, top)
lineTo(right, top)
lineTo(right, bottom - radius)
if (radius > 0f) {
quadTo(right, bottom, right - radius, bottom)
lineTo(left + radius, bottom)
quadTo(left, bottom, left, bottom - radius)
} else {
lineTo(right, bottom)
lineTo(left, bottom)
}
closePath()
}
g2.color = JBUI.CurrentTheme.Focus.focusColor()
g2.stroke = BasicStroke(line.toFloat(), BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND)
g2.draw(path)
} finally {
g2.dispose()
}
}
private fun editorFocused(): Boolean {
val ed = editor.getEditor(false) ?: return editor.hasFocus()
return editor.hasFocus() || ed.contentComponent.hasFocus()
}
@RequiresEdt
fun setReady(value: Boolean) {
ready = value
@@ -369,8 +426,8 @@ class PromptPanel(
this.style = style
background = style.editorScheme.defaultBackground
shell.background = style.editorScheme.defaultBackground
editor.font = style.editorFont
editor.getEditor(false)?.let(style::applyToEditor)
editor.font = style.transcriptFont
editor.getEditor(false)?.let(style::applyTranscriptToEditor)
editor.background = style.editorScheme.defaultBackground
syncEditorHeight()
syncAutoApprove()
@@ -517,21 +574,26 @@ class PromptPanel(
val txt = text()
val items = attachments.toList()
submitting = true
ApplicationManager.getApplication().executeOnPooledThread {
cs.launch {
try {
val files = items.map { it.part() }
val files = withContext(Dispatchers.IO) { items.map { it.part() } }
val mentioned = onMentions(txt)
ApplicationManager.getApplication().invokeLater {
withContext(Dispatchers.Main) {
submitting = false
if (project.isDisposed) return@invokeLater
if (project.isDisposed) return@withContext
val parts = files + mentioned
LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, parts))} src=$src busy=$busy" }
onSend(txt, parts)
}
} catch (e: Exception) {
ApplicationManager.getApplication().invokeLater {
} catch (e: CancellationException) {
withContext(NonCancellable + Dispatchers.Main) {
submitting = false
if (project.isDisposed) return@invokeLater
}
throw e
} catch (e: Exception) {
withContext(Dispatchers.Main) {
submitting = false
if (project.isDisposed) return@withContext
LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e)
notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName))
}
@@ -49,6 +49,19 @@ data class SessionEditorStyle(
}
}
/** Apply editor colors while using standard transcript typography for the embedded editor text. */
fun applyTranscriptToEditor(editor: EditorEx) {
try {
if (editor.isDisposed) return
applyToEditor(editor)
if (editor.isDisposed) return
editor.colorsScheme.setEditorFontName(transcriptFont.fontName)
editor.colorsScheme.setEditorFontSize(transcriptFont.size)
} catch (err: RuntimeException) {
if (err.javaClass.name != "com.intellij.openapi.util.TraceableDisposable\$DisposalException") throw err
}
}
companion object {
/** Builds a style snapshot from the current global editor color scheme. */
fun current(): SessionEditorStyle {
@@ -165,6 +165,7 @@ object SessionUiStyle {
/** Tool session-view preview limits and state colors. */
object Tool {
const val BODY_LINES = 15
const val TASK_LINES = 10
const val PREVIEW_LIMIT = 20_000
fun pending(): Color = UiStyle.Colors.weak()
@@ -37,6 +37,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par
init {
layout = BorderLayout()
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.md(), 0)
applyStyle(SessionEditorStyle.current())
val line = { JPanel().apply {
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.FileAttachment
import ai.kilocode.client.session.model.Message
@@ -59,7 +60,14 @@ class MessageView(
val role: String get() = msg.info.role
override val sessionViewKind: SessionView.Kind
get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default
get() = if (role == SessionUiStyle.View.Message.USER_ROLE && !compaction) {
SessionView.Kind.UserPrompt
} else {
SessionView.Kind.Default
}
private val compaction: Boolean
get() = role == SessionUiStyle.View.Message.USER_ROLE && msg.parts.values.any { it is Compaction }
private val parts = LinkedHashMap<String, PartView>()
// Adjacent reasoning parts render through the first ReasoningView. aliases maps each
@@ -411,7 +419,7 @@ class MessageView(
}
override fun paintComponent(g: Graphics) {
if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) {
if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE || compaction) {
super.paintComponent(g)
return
}
@@ -72,7 +72,7 @@ class PromptView(
md.linkColor = color
}
override fun styleFont(style: SessionEditorStyle) = style.editorFont
override fun styleFont(style: SessionEditorStyle) = style.transcriptFont
override fun styleBackground(style: SessionEditorStyle) = style.editorBackground
@@ -8,6 +8,7 @@ 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.ShellToolView
import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.model.Compaction
@@ -53,6 +54,7 @@ object ViewFactory {
GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo)
SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo)
ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection)
TaskToolView.canRender(content) -> TaskToolView(content, selection = selection)
else -> ToolView(content, selection = selection)
}
is Compaction -> CompactionView(content)
@@ -98,6 +100,8 @@ object ViewFactory {
if (view !is SearchToolView && SearchToolView.canRender(content)) return true
if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content)
if (view is ToolView && ReadToolView.canRender(content)) return true
if (view is TaskToolView) return !TaskToolView.canRender(content) || QuestionResultView.canRender(content)
if (view !is TaskToolView && TaskToolView.canRender(content)) return true
if (view is ToolView) return QuestionResultView.canRender(content)
return false
}
@@ -164,8 +164,8 @@ class QuestionView(
this.style = style
card.applyStyle(style)
customEditor?.let { ed ->
ed.font = style.editorFont
ed.getEditor(false)?.let(style::applyToEditor)
ed.font = style.transcriptFont
ed.getEditor(false)?.let(style::applyTranscriptToEditor)
ed.background = style.editorScheme.defaultBackground
}
val changed = texts.fold(false) { acc, item -> setFont(item.first, item.second) || acc }
@@ -490,12 +490,11 @@ class QuestionView(
private fun buildCustomEditor(): SessionEditorTextField {
val ed = SessionEditorTextField(project, selection = selection)
ed.border = JBUI.Borders.empty()
ed.setFontInheritedFromLAF(false)
ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder"))
ed.setShowPlaceholderWhenFocused(true)
ed.setOneLineMode(false)
ed.addSettingsProvider { ex ->
style.applyToEditor(ex)
style.applyTranscriptToEditor(ex)
ex.setBorder(JBUI.Borders.empty())
ex.scrollPane.border = JBUI.Borders.empty()
ex.scrollPane.viewportBorder = JBUI.Borders.empty()
@@ -507,7 +506,7 @@ class QuestionView(
ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
}
selection?.register(ed)?.let(regs::add)
ed.font = style.editorFont
ed.font = style.transcriptFont
ed.background = style.editorScheme.defaultBackground
// Pre-fill with saved text. This call also forces lazy document creation so
@@ -4,26 +4,25 @@ import ai.kilocode.client.plugin.KiloBundle
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.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import ai.kilocode.rpc.dto.TodoDto
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.xml.util.XmlStringUtil
import java.awt.BasicStroke
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import javax.swing.Icon
import javax.swing.BoxLayout
import javax.swing.JPanel
class TodoListPanel(
todos: List<TodoDto> = emptyList(),
private var before: Int = 0,
private var after: Int = 0,
) : JPanel() {
) : Stack(StackAxis.VERTICAL) {
private var items = todos
private var style = SessionEditorStyle.current()
@@ -32,11 +31,7 @@ class TodoListPanel(
private val later = JBLabel()
init {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md())
add(prior)
add(later)
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad())
applyStyle(style)
sync()
}
@@ -86,13 +81,13 @@ class TodoListPanel(
private fun sync() {
removeAll()
rows.clear()
add(prior)
next(prior)
items.forEach { todo ->
val row = Row(todo, style)
rows.add(row)
add(row.panel)
next(row.panel)
}
add(later)
next(later)
syncHidden()
}
@@ -122,11 +117,10 @@ class TodoListPanel(
icon = this@Row.icon
}
val text = JBLabel()
val panel = JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
val panel = Stack.horizontal(UiStyle.Gap.sm()).apply {
border = JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
add(check, BorderLayout.WEST)
add(text, BorderLayout.CENTER)
next(check)
next(text)
}
init {
@@ -35,7 +35,7 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
0,
0,
),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)
applyStyle(style)
sync()
@@ -210,6 +210,7 @@ class ShellToolView(
md.codeFont = style.editorFamily
md.component.border = JBUI.Borders.empty()
md.set(popupMd(formatCommand(cmd)))
padPopup(md.component)
return HeaderPopupBody(PopupPanel(md.component), md, style.editorBackground)
}
@@ -220,6 +221,22 @@ class ShellToolView(
}
}
private fun padPopup(root: JComponent) {
root.components.filterIsInstance<JBScrollPane>().forEach { pane ->
val field = pane.viewport.view as? EditorTextField ?: return@forEach
field.border = JBUI.Borders.empty(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT, 0, 0, 0)
val pad = field.border.getBorderInsets(field).top
field.preferredSize = grow(field.preferredSize, pad)
field.minimumSize = grow(field.minimumSize, pad)
field.maximumSize = grow(field.maximumSize, pad)
pane.preferredSize = grow(pane.preferredSize, pad)
pane.minimumSize = grow(pane.minimumSize, pad)
pane.maximumSize = grow(pane.maximumSize, pad)
}
}
private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad)
private class PopupPanel(child: JComponent) : JPanel(BorderLayout()) {
init {
// Transparent so the balloon fill (editor background) shows uniformly behind the content.
@@ -0,0 +1,344 @@
package ai.kilocode.client.session.views.tool
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.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 ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.layout.StackAxis
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.Point
import java.awt.Rectangle
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.Scrollable
import javax.swing.SwingUtilities
import kotlin.math.abs
class TaskToolView(
tool: Tool,
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool),
) : SecondarySessionPartView(parts.header, { TaskBody(parts.glyph).scroll }), UiDataProvider {
override val contentId: String = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private val rows = LinkedHashMap<String, Row>()
private var following = false
private var collapsed = false
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
applyStyle(style)
sync()
if (item.childTools.isNotEmpty()) expand()
}
override fun uiDataSnapshot(sink: DataSink) {
selection?.provideCopy(sink) { copyText() }
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Tool) return
val fresh = item.childTools.isEmpty() && content.childTools.isNotEmpty()
item = content
val follow = tailVisible()
var changed = sync()
changed = syncRows() || changed
if (content.childTools.isNotEmpty() && !collapsed) changed = expand() || changed
followTail(follow || fresh)
if (changed) refresh()
}
@RequiresEdt
override fun expand(): Boolean {
collapsed = false
val changed = super.expand()
syncRows()
return changed
}
@RequiresEdt
override fun collapse(): Boolean {
if (item.childTools.isNotEmpty() && isExpanded()) collapsed = true
return super.collapse()
}
@RequiresEdt
private fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
.filter { it.isNotBlank() }
.joinToString(" ")
@RequiresEdt
private fun bodyVisible(): Boolean = isExpanded()
@RequiresEdt
private fun bodyMaxRows() = SessionUiStyle.View.Tool.TASK_LINES
@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.state, style.smallEditorFont) || changed
for (row in rows.values) changed = row.applyStyle(style) || changed
if (changed) refresh()
}
@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))
}
private fun sync(): Boolean {
var changed = false
changed = syncExpandable(item.childTools.isNotEmpty()) || changed
changed = setVisible(parts.state, item.childTools.isEmpty()) || changed
changed = setIcon(parts.glyph, icon(item)) || changed
changed = setForeground(parts.glyph, color(item)) || changed
changed = setText(parts.title, agentTitle(item)) || changed
changed = setText(parts.sub, summary(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 syncRows(): Boolean {
if (!hasBody()) return false
val body = taskBody()
var changed = false
val ids = item.childTools.map { tool -> tool.id }.toSet()
val stale = rows.keys.filter { id -> id !in ids }
for (id in stale) {
val row = rows.remove(id) ?: continue
body.rows.remove(row.panel)
changed = true
}
for (tool in item.childTools) {
val row = rows[tool.id]
if (row == null) {
val next = Row(tool).also { it.applyStyle(style) }
rows[tool.id] = next
body.rows.next(next.panel)
changed = true
continue
}
changed = row.update(tool) || changed
}
if (changed) {
body.rows.revalidate()
body.rows.repaint()
}
return changed
}
private fun taskBody() = bodyComponent() as TaskBodyScroll
private fun taskBodyOrNull() = if (hasBody()) bodyComponent() as? TaskBodyScroll else null
private fun bodyMaxHeight(): Int {
val body = taskBodyOrNull() ?: return 0
val height = rows.values.firstOrNull()?.panel?.getFontMetrics(style.smallEditorFont)?.height
?: body.rows.getFontMetrics(style.smallEditorFont).height
return height * bodyMaxRows() + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT)
}
@RequiresEdt
private fun tailVisible(): Boolean {
if (!bodyVisible()) return false
val scroll = taskBodyOrNull() ?: return false
val bar = scroll.verticalScrollBar
val bottom = bar.maximum - bar.visibleAmount
return bottom > 0 && abs(bar.value - bottom) <= UiStyle.Gap.pad()
}
@RequiresEdt
private fun followTail(follow: Boolean) {
if (!follow || !bodyVisible() || following) return
val scroll = taskBodyOrNull() ?: return
following = true
SwingUtilities.invokeLater { followPass(scroll, 4) }
}
@RequiresEdt
private fun followPass(scroll: JBScrollPane, passes: Int) {
if (!bodyVisible()) {
following = false
return
}
val view = scroll.viewport.view
view?.setSize(scroll.viewport.extentSize.width.coerceAtLeast(1), view.preferredSize.height)
view?.doLayout()
scroll.viewport.doLayout()
scroll.doLayout()
scroll.viewport.viewPosition = Point(0, bottom(scroll))
scroll.verticalScrollBar.value = bottom(scroll)
if (passes <= 0 || scroll.verticalScrollBar.value == bottom(scroll)) {
following = false
return
}
SwingUtilities.invokeLater { followPass(scroll, passes - 1) }
}
private fun bottom(scroll: JBScrollPane): Int {
val view = scroll.viewport.view ?: return 0
return maxOf(0, view.height - scroll.viewport.extentSize.height)
}
private fun copyText(): String = buildString {
append(agentTitle(item))
val desc = item.input["description"].orEmpty()
if (desc.isNotBlank()) append(" - ").append(desc)
for (tool in item.childTools) {
append('\n')
append(title(tool))
val sub = subtitle(tool)
if (sub.isNotBlank()) append(' ').append(sub)
}
}
private class Row(tool: Tool) {
private var item = tool
val icon = JBLabel()
val title = JBLabel()
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
val panel = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
isOpaque = false
add(icon, BorderLayout.WEST)
add(JPanel(BorderLayout(UiStyle.Gap.sm(), 0)).apply {
isOpaque = false
add(title, BorderLayout.WEST)
add(sub, BorderLayout.CENTER)
}, BorderLayout.CENTER)
}
@RequiresEdt
fun update(tool: Tool): Boolean {
item = tool
var changed = false
changed = setIcon(icon, icon(tool)) || changed
changed = setForeground(icon, color(tool)) || changed
changed = setText(title, title(tool)) || changed
changed = setForeground(title, rowTitleColor(tool)) || changed
changed = setText(sub, subtitle(tool)) || changed
return changed
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle): Boolean {
var changed = false
changed = setFont(title, style.boldEditorFont) || changed
changed = setFont(sub, style.smallEditorFont) || changed
return update(item) || changed
}
}
override fun dumpLabel() = "TaskToolView#$contentId(${labelText()})"
companion object {
fun canRender(content: Tool): Boolean = content.name == "task"
}
}
private class TaskBody(glyph: JBLabel) {
val rows = TaskRows()
val panel = object : JPanel(BorderLayout()) {
override fun updateUI() {
super.updateUI()
background = SessionUiStyle.View.Surface.bgColor()
border = taskBodyBorder(glyph)
}
}.apply {
add(rows, BorderLayout.CENTER)
}
val scroll = TaskBodyScroll(this)
}
private class TaskBodyScroll(val body: TaskBody) : JBScrollPane(body.panel) {
val rows: Stack get() = body.rows
val panel: JPanel get() = body.panel
init {
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.empty()
background = SessionUiStyle.View.Surface.bgColor()
viewport?.background = SessionUiStyle.View.Surface.bgColor()
}
}
private class TaskRows : Stack(StackAxis.VERTICAL, UiStyle.Gap.sm()), Scrollable {
override fun getScrollableTracksViewportWidth() = true
override fun getScrollableTracksViewportHeight() = false
override fun getPreferredScrollableViewportSize(): Dimension = preferredSize
override fun getScrollableUnitIncrement(
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT)
override fun getScrollableBlockIncrement(
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = visibleRect.height
override fun getMaximumSize() = JBDimension(Int.MAX_VALUE, super.getMaximumSize().height)
}
private fun rowTitleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) {
UiStyle.Colors.errorLabelForeground()
} else {
UiStyle.Colors.weak()
}
private fun taskBodyBorder(glyph: JBLabel) = run {
val width = maxOf(
glyph.preferredSize.width,
glyph.icon?.iconWidth ?: 0,
JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING),
)
JBUI.Borders.empty(
UiStyle.Gap.sm(),
width + JBUI.scale(SessionUiStyle.View.Layout.GAP) + UiStyle.Gap.md(),
UiStyle.Gap.sm(),
UiStyle.Gap.md(),
)
}
private fun agentTitle(tool: Tool): String {
val type = tool.input["subagent_type"]?.takeIf { it.isNotBlank() } ?: tool.name
return KiloBundle.message("session.part.tool.agent", type.replaceFirstChar { it.titlecase() })
}
private fun summary(tool: Tool): String {
val desc = tool.input["description"].orEmpty()
val count = tool.childTools.size
if (count <= 0) return desc
if (desc.isBlank()) return "($count)"
return "$desc ($count)"
}
@@ -2,11 +2,14 @@ package ai.kilocode.client.settings.base
import ai.kilocode.client.ui.UiStyle
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import java.awt.Component
import java.awt.Container
import java.awt.Point
import java.awt.Rectangle
import javax.swing.Icon
import javax.swing.JList
import javax.swing.ListCellRenderer
import javax.swing.SwingUtilities
private const val CELL_GAP = 8
@@ -57,55 +60,64 @@ internal fun settingsListVisibleCells(item: SettingsListItem, selected: Boolean)
return item.cells.filter { selected || it.alwaysVisible }
}
internal fun settingsListCellGap() = JBUI.scale(CELL_GAP)
/**
* Clickable action-cell rectangles for a row, in list coordinates.
*
* The rectangles are read back from the actual rendered component tree instead of being
* re-derived by hand. This keeps the click targets identical to what the [SettingsListRenderer]
* draws — including the horizontal insets the platform's [com.intellij.ui.popup.list.SelectablePanel]
* adds in the New UI, which a hand-computed layout would miss.
*/
internal fun settingsListCellBounds(
list: JList<*>,
index: Int,
selected: Boolean,
): Map<String, Rectangle> {
val model = list.model
if (index < 0 || index >= model.size) return emptyMap()
@Suppress("UNCHECKED_CAST")
val renderer = list.cellRenderer as? ListCellRenderer<Any?> ?: return emptyMap()
val cell = list.getCellBounds(index, index) ?: return emptyMap()
val comp = renderer.getListCellRendererComponent(list, model.getElementAt(index), index, selected, list.hasFocus())
comp.setBounds(0, 0, cell.width, cell.height)
settingsListLayout(comp)
val out = linkedMapOf<String, Rectangle>()
for (action in settingsListActionCells(comp)) {
val origin = SwingUtilities.convertPoint(action, 0, 0, comp)
out[action.cellId] = Rectangle(cell.x + origin.x, cell.y + origin.y, action.width, action.height)
}
return out
}
internal fun settingsListCellAt(
list: JList<*>,
bounds: Rectangle,
index: Int,
point: Point,
item: SettingsListItem,
selected: Boolean,
): String? {
val cells = settingsListCellBounds(list, bounds, item, selected)
val model = list.model
if (index < 0 || index >= model.size) return null
val item = model.getElementAt(index) as? SettingsListItem ?: return null
val cells = settingsListCellBounds(list, index, selected)
return settingsListVisibleCells(item, selected)
.firstOrNull { cell -> cell.enabled && cells[cell.id]?.contains(point) == true }
?.id
}
internal fun settingsListCellBounds(
list: JList<*>,
bounds: Rectangle,
item: SettingsListItem,
selected: Boolean,
): Map<String, Rectangle> {
val height = settingsListCellHeight(list)
var edge = bounds.x + bounds.width - UiStyle.Gap.pad()
val out = linkedMapOf<String, Rectangle>()
for (cell in settingsListVisibleCells(item, selected).asReversed()) {
val size = settingsListCellSize(list, cell)
val width = size.width
val h = height.coerceAtLeast(size.height)
val top = bounds.y + (bounds.height - h) / 2
val left = edge - width
out[cell.id] = Rectangle(left, top, width, h)
edge = left - JBUI.scale(CELL_GAP)
private fun settingsListLayout(component: Component) {
if (component !is Container) return
component.doLayout()
for (child in component.components) settingsListLayout(child)
}
private fun settingsListActionCells(component: Component): List<SettingsListActionCell> {
val out = mutableListOf<SettingsListActionCell>()
fun visit(c: Component) {
if (c is SettingsListActionCell && c.isVisible) out += c
if (c is Container) c.components.forEach(::visit)
}
visit(component)
return out
}
internal fun settingsListCellSize(list: JList<*>, cell: SettingsListCell): Dimension {
val label = SettingsListActionCell().apply {
update(cell)
font = list.font
isEnabled = cell.enabled
}
val size = label.preferredSize
if (!cell.iconOnly) return size
val min = settingsListCellHeight(list)
return Dimension(size.width.coerceAtLeast(min), size.height.coerceAtLeast(min))
}
private fun settingsListCellHeight(list: JList<*>): Int {
val metrics = list.getFontMetrics(list.font)
return metrics.height + UiStyle.Gap.sm() * 2
}
internal fun settingsListCellGap() = JBUI.scale(CELL_GAP)
@@ -133,7 +133,11 @@ internal class SettingsListRenderer(
}
internal class SettingsListActionCell : JBLabel() {
var cellId: String = ""
private set
fun update(cell: SettingsListCell) {
cellId = cell.id
text = if (cell.iconOnly) "" else cell.label
icon = cell.icon
toolTipText = cell.label.takeIf { it.isNotBlank() }
@@ -203,9 +203,9 @@ internal class SettingsListView(
val item = model.getElementAt(idx)
val selected = idx == list.selectedIndex
val id = if (enabled) {
settingsListCellAt(list, bounds, e.point, item, selected)
settingsListCellAt(list, idx, e.point, selected)
} else {
settingsListCellBounds(list, bounds, item, selected)
settingsListCellBounds(list, idx, selected)
.entries
.firstOrNull { it.value.contains(e.point) }
?.key
@@ -0,0 +1,226 @@
package ai.kilocode.client.ui.md.hybrid
import com.intellij.openapi.fileTypes.PlainTextFileType
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TableBlock
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Block
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
internal class MdProjector {
private val extensions = listOf(
AutolinkExtension.create(),
TablesExtension.create(),
StrikethroughExtension.create(),
)
private val parser: Parser = Parser.builder().extensions(extensions).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder()
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
.build()
fun project(text: String): Projection {
val blocks = mutableListOf<Desc>()
val html = StringBuilder()
val md = StringBuilder()
val lines = lines(text)
var trailing: Fence? = null
var idx = 0
fun flush() {
if (md.isEmpty()) return
val doc = parser.parse(md.toString())
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))
is Desc.Table -> html.append(desc.body)
}
}
md.clear()
}
while (idx < lines.size) {
val line = lines[idx]
val open = opener(line.text)
if (open == null) {
val pending = idx == lines.lastIndex && pendingOpener(line.text)
if (pending) {
flush()
blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE)))
html.append(codeHtml(""))
} else {
md.append(line.text).append(line.end)
}
idx++
continue
}
flush()
idx++
val code = StringBuilder()
var closed = false
var trimmed = false
while (idx < lines.size) {
val item = lines[idx]
val close = closer(item.text, open)
if (close) {
closed = true
idx++
break
}
val partial = idx == lines.lastIndex && partialCloser(item.text, open)
if (partial) trimmed = true
if (!partial) code.append(item.text).append(item.end)
idx++
}
val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info))
blocks.add(desc)
html.append(codeHtml(desc.text))
trailing = if (!closed && !trimmed) open else null
}
flush()
return Projection(html.toString(), blocks, trailing)
}
private fun collect(doc: Node): List<Desc> {
val visitor = Visitor()
doc.accept(visitor)
return visitor.blocks
}
private fun lines(text: String): List<Line> {
if (text.isEmpty()) return emptyList()
val lines = mutableListOf<Line>()
var start = 0
while (start < text.length) {
val end = text.indexOf('\n', start)
if (end == -1) {
lines.add(Line(text.substring(start), ""))
break
}
lines.add(Line(text.substring(start, end), "\n"))
start = end + 1
}
return lines
}
private fun opener(text: String): Fence? {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return null
val char = trimmed.firstOrNull() ?: return null
if (char != '`' && char != '~') return null
val size = trimmed.takeWhile { it == char }.length
if (size < 3) return null
val info = trimmed.drop(size).trim()
if (char == '`' && info.contains('`')) return null
return Fence(char, size, info)
}
private fun closer(text: String, fence: Fence): Boolean {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return false
val size = trimmed.takeWhile { it == fence.char }.length
if (size < fence.size) return false
return trimmed.drop(size).isBlank()
}
private fun pendingOpener(text: String): Boolean {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return false
val char = trimmed.firstOrNull() ?: return false
if (char != '`' && char != '~') return false
val size = trimmed.takeWhile { it == char }.length
if (size !in 1..2) return false
return trimmed.drop(size).isBlank()
}
private fun partialCloser(text: String, fence: Fence): Boolean {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return false
val size = trimmed.takeWhile { it == fence.char }.length
if (size !in 1 until fence.size) return false
return trimmed.drop(size).isBlank()
}
private fun codeHtml(text: String): String = "<pre><code>${escape(text)}</code></pre>\n"
private fun escape(text: String): String = text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private inner class Visitor : AbstractVisitor() {
val blocks = mutableListOf<Desc>()
private val run = StringBuilder()
override fun visit(document: Document) {
visitChildren(document)
flush()
}
override fun visit(code: FencedCodeBlock) {
flush()
blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info)))
}
override fun visit(code: IndentedCodeBlock) {
flush()
blocks.add(Desc.Code(code.literal, MdLanguage.kind(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
when {
child is ThematicBreak -> Unit
child is FencedCodeBlock || child is IndentedCodeBlock -> child.accept(this)
child is TableBlock -> {
flush()
blocks.add(Desc.Table(renderer.render(child)))
}
child is Block -> run.append(renderer.render(child))
}
child = next
}
}
}
}
internal sealed class Desc {
data class Html(val body: String) : Desc()
data class Code(val text: String, val kind: Kind) : Desc()
data class Table(val body: String) : Desc()
}
internal data class Projection(val html: String, val blocks: List<Desc>, val open: Fence?)
internal data class Line(val text: String, val end: String)
internal data class Fence(val char: Char, val size: Int, val info: String)
@@ -42,20 +42,6 @@ internal object MdTerminal {
}
}
fun backspace(text: String): String {
val out = StringBuilder()
var idx = 0
while (idx < text.length) {
val ch = text[idx++]
if (ch == '\b') {
if (out.isNotEmpty()) out.deleteCharAt(out.length - 1)
continue
}
out.append(ch)
}
return out.toString()
}
fun reduce(text: String, keepSgr: Boolean): String = split(text.replace("\r\n", "\n"), '\n')
.joinToString("\n") { controls(it, keepSgr) }
@@ -15,6 +15,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
import com.intellij.openapi.fileTypes.FileType
@@ -27,19 +28,8 @@ import com.intellij.ui.components.JBHtmlPaneConfiguration
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Block
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
import java.awt.Component
import java.awt.Dimension
import java.awt.Font
import java.awt.Point
@@ -55,6 +45,7 @@ import javax.swing.SwingUtilities
import javax.swing.event.ChangeListener
import javax.swing.event.HyperlinkEvent
import javax.swing.text.html.StyleSheet
import kotlin.reflect.KProperty
@Suppress("UnstableApiUsage")
internal open class MdViewHybrid(
@@ -75,32 +66,19 @@ internal open class MdViewHybrid(
private val blocks = mutableListOf<View>()
private var openFence: Fence? = null
private var stale = false
private val projector = MdProjector()
private val extensions = listOf(
AutolinkExtension.create(),
TablesExtension.create(),
StrikethroughExtension.create(),
)
private val parser: Parser = Parser.builder().extensions(extensions).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder()
.extensions(extensions)
.escapeHtml(true)
.sanitizeUrls(true)
.build()
private var fontOverride: Font? = null
private var foregroundOverride: Color? = null
private var backgroundOverride: Color? = null
private var linkColorOverride: Color? = null
private var codeBgOverride: Color? = null
private var preBgOverride: Color? = null
private var preFgOverride: Color? = null
private var codeFontOverride: String? = null
private var quoteBorderOverride: Color? = null
private var quoteFgOverride: Color? = null
private var tableBorderOverride: Color? = null
private val fontOverride = Override { opts().font }
private val foregroundOverride = Override { opts().foreground }
private val backgroundOverride = Override { opts().background }
private val linkColorOverride = Override { opts().linkColor }
private val codeBgOverride = Override { opts().codeBg }
private val preBgOverride = Override { opts().preBg }
private val preFgOverride = Override { opts().preFg }
private val codeFontOverride = Override { opts().codeFont }
private val quoteBorderOverride = Override { opts().quoteBorder }
private val quoteFgOverride = Override { opts().quoteFg }
private val tableBorderOverride = Override { opts().tableBorder }
private var opaqueState = true
private val root = RootPanel().apply {
@@ -111,104 +89,27 @@ internal open class MdViewHybrid(
override val component: JComponent get() = root
override var font: Font
get() = fontOverride ?: opts().font
set(value) {
if (disposed) return
if (fontOverride == value) return
fontOverride = value
syncStyle()
}
override var font: Font by fontOverride
override var foreground: Color
get() = foregroundOverride ?: opts().foreground
set(value) {
if (disposed) return
if (foregroundOverride == value) return
foregroundOverride = value
syncStyle()
}
override var foreground: Color by foregroundOverride
override var background: Color
get() = backgroundOverride ?: opts().background
set(value) {
if (disposed) return
if (backgroundOverride == value) return
backgroundOverride = value
syncStyle()
}
override var background: Color by backgroundOverride
override var linkColor: Color
get() = linkColorOverride ?: opts().linkColor
set(value) {
if (disposed) return
if (linkColorOverride == value) return
linkColorOverride = value
syncStyle()
}
override var linkColor: Color by linkColorOverride
override var codeBg: Color
get() = codeBgOverride ?: opts().codeBg
set(value) {
if (disposed) return
if (codeBgOverride == value) return
codeBgOverride = value
syncStyle()
}
override var codeBg: Color by codeBgOverride
override var preBg: Color
get() = preBgOverride ?: opts().preBg
set(value) {
if (disposed) return
if (preBgOverride == value) return
preBgOverride = value
syncStyle()
}
override var preBg: Color by preBgOverride
override var preFg: Color
get() = preFgOverride ?: opts().preFg
set(value) {
if (disposed) return
if (preFgOverride == value) return
preFgOverride = value
syncStyle()
}
override var preFg: Color by preFgOverride
override var codeFont: String
get() = codeFontOverride ?: opts().codeFont
set(value) {
if (disposed) return
if (codeFontOverride == value) return
codeFontOverride = value
syncStyle()
}
override var codeFont: String by codeFontOverride
override var quoteBorder: Color
get() = quoteBorderOverride ?: opts().quoteBorder
set(value) {
if (disposed) return
if (quoteBorderOverride == value) return
quoteBorderOverride = value
syncStyle()
}
override var quoteBorder: Color by quoteBorderOverride
override var quoteFg: Color
get() = quoteFgOverride ?: opts().quoteFg
set(value) {
if (disposed) return
if (quoteFgOverride == value) return
quoteFgOverride = value
syncStyle()
}
override var quoteFg: Color by quoteFgOverride
override var tableBorder: Color
get() = tableBorderOverride ?: opts().tableBorder
set(value) {
if (disposed) return
if (tableBorderOverride == value) return
tableBorderOverride = value
syncStyle()
}
override var tableBorder: Color by tableBorderOverride
override var opaque: Boolean
get() = opaqueState
@@ -236,17 +137,17 @@ internal open class MdViewHybrid(
override fun resetStyles() {
if (disposed) return
fontOverride = null
foregroundOverride = null
backgroundOverride = null
linkColorOverride = null
codeBgOverride = null
preBgOverride = null
preFgOverride = null
codeFontOverride = null
quoteBorderOverride = null
quoteFgOverride = null
tableBorderOverride = null
fontOverride.clear()
foregroundOverride.clear()
backgroundOverride.clear()
linkColorOverride.clear()
codeBgOverride.clear()
preBgOverride.clear()
preFgOverride.clear()
codeFontOverride.clear()
quoteBorderOverride.clear()
quoteFgOverride.clear()
tableBorderOverride.clear()
opaqueState = true
syncStyle()
}
@@ -302,7 +203,7 @@ internal open class MdViewHybrid(
override fun html(): String {
if (stale) {
val out = project(source.toString())
val out = projector.project(source.toString())
rendered = out.html
openFence = out.open
stale = false
@@ -341,7 +242,7 @@ internal open class MdViewHybrid(
private fun syncBlocks() {
if (disposed) return
val text = source.toString()
val out = project(text)
val out = projector.project(text)
rendered = out.html
openFence = out.open
stale = false
@@ -411,6 +312,7 @@ internal open class MdViewHybrid(
val disposable = Disposer.newDisposable("Markdown block")
return when (desc) {
is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable)
is Desc.Table -> TableView(desc, tableBlock(desc.body, disposable), disposable)
is Desc.Code -> when (val kind = desc.kind) {
is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable)
is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable)
@@ -500,22 +402,37 @@ internal open class MdViewHybrid(
}.getOrNull()
}
private fun tableBlock(body: String, disposable: Disposable): JBScrollPane {
val opts = opts()
val inner = htmlBlock(body, disposable)
val pane = object : JBScrollPane(inner), SessionCopyTarget {
override val copyAnchor: JComponent get() = this
override fun copyText() = inner.document.getText(0, inner.document.length).trim()
// Width is pinned to 0 so BoxLayout shrinks the pane to the container while the wide
// table scrolls horizontally inside it. Height is derived from the inner pane's current
// preferred height on every pass so it is correct once the html view is realized
// (a static measurement taken before layout is too small and crops the table).
override fun getPreferredSize() = Dimension(0, tableHeight(this, inner))
override fun getMinimumSize() = Dimension(0, tableHeight(this, inner))
override fun getMaximumSize() = Dimension(Int.MAX_VALUE, tableHeight(this, inner))
}
styleTablePane(pane, opts)
return pane
}
private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane {
val opts = opts()
val value = text.trimEnd('\n')
fun editor(type: FileType) = CodeField(type, opts, text, false).also { ed ->
Disposer.register(disposable) {
ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor)
}
ed.setDisposedWith(disposable)
selection?.register(ed, disposable)
}
val field = runCatching {
editor(file)
codeField(file, opts, text, false, disposable)
}.getOrElse { err ->
LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err)
if (code.opts.editorOnly) runCatching {
editor(PlainTextFileType.INSTANCE)
codeField(PlainTextFileType.INSTANCE, opts, text, false, disposable)
}.getOrElse { fallback ->
LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback)
throw fallback
@@ -524,23 +441,10 @@ internal open class MdViewHybrid(
}
}
sizeCodeField(field, value)
val pane = object : JBScrollPane(field), SessionCopyTarget {
val pane = object : CodePane(field), SessionCopyTarget {
override val copyAnchor: JComponent get() = this
override fun copyText() = when (field) {
is CodeField -> field.text
is JBTextArea -> field.text
else -> ""
}
override fun doLayout() {
super.doLayout()
if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return
val view = viewport.view ?: return
val size = viewport.extentSize
if (size.height <= 0 || view.height == size.height) return
view.setSize(view.width.coerceAtLeast(size.width), size.height)
}
override fun copyText() = fieldText(field)
}
styleCodePane(pane, opts)
sizeCodePane(pane, field)
@@ -551,27 +455,12 @@ internal open class MdViewHybrid(
val opts = opts()
val term = MdTerminal.decode(text, kind.stream)
val value = shellDisplay(term, kind.mode)
val field = CodeField(PlainTextFileType.INSTANCE, opts, value.text, false).also { ed ->
Disposer.register(disposable) {
ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor)
}
ed.setDisposedWith(disposable)
selection?.register(ed, disposable)
}
val field = codeField(PlainTextFileType.INSTANCE, opts, value.text, false, disposable)
sizeCodeField(field, value.text)
val pane = object : JBScrollPane(field), SessionCopyTarget {
val pane = object : CodePane(field), SessionCopyTarget {
override val copyAnchor: JComponent get() = this
override fun copyText() = field.text
override fun doLayout() {
super.doLayout()
if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return
val view = viewport.view ?: return
val size = viewport.extentSize
if (size.height <= 0 || view.height == size.height) return
view.setSize(view.width.coerceAtLeast(size.width), size.height)
}
}
styleCodePane(pane, opts)
sizeCodePane(pane, field)
@@ -610,8 +499,39 @@ internal open class MdViewHybrid(
}
}
private fun codeField(file: FileType, opts: MdStyle, text: String, soft: Boolean, disposable: Disposable) =
CodeField(file, opts, text, soft).also { ed ->
Disposer.register(disposable) {
ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor)
}
ed.setDisposedWith(disposable)
selection?.register(ed, disposable)
}
private fun applyEditorChrome(ed: EditorEx, opts: MdStyle, soft: Boolean) {
style.applyToEditor(ed)
ed.setBorder(JBUI.Borders.empty())
ed.scrollPane.border = JBUI.Borders.empty()
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
ed.backgroundColor = opts.preBg
ed.scrollPane.background = opts.preBg
ed.scrollPane.isOpaque = true
ed.scrollPane.viewport.isOpaque = true
ed.scrollPane.viewport.background = opts.preBg
ed.settings.isUseSoftWraps = soft
ed.settings.isAdditionalPageAtBottom = false
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
}
private fun fieldText(component: Component): String = when (component) {
is CodeField -> component.text
is JBTextArea -> component.text
else -> ""
}
private fun sizeCodeField(component: JComponent, text: String) {
val height = codeHeight(component, text)
val height = codeHeight(component, text, null)
val width = codeWidth(component, text)
component.preferredSize = Dimension(width, height)
component.minimumSize = Dimension(0, height)
@@ -620,12 +540,8 @@ internal open class MdViewHybrid(
private fun sizeCodePane(pane: JBScrollPane, component: JComponent) {
val pad = pane.viewportBorder.getBorderInsets(pane)
val text = when (component) {
is CodeField -> component.text
is JBTextArea -> component.text
else -> ""
}
val content = visibleCodeHeight(component, text)
val text = fieldText(component)
val content = codeHeight(component, text, code.opts.maxLines)
val height = content + pane.insets.top + pane.insets.bottom +
pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height
pane.preferredSize = Dimension(0, height)
@@ -633,41 +549,52 @@ internal open class MdViewHybrid(
pane.maximumSize = Dimension(Int.MAX_VALUE, height)
}
private fun styleTablePane(pane: JBScrollPane, opts: MdStyle) {
pane.apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
isOpaque = opts.opaque
background = opts.background
viewport.isOpaque = opts.opaque
viewport.background = opts.background
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
isWheelScrollingEnabled = true
setOverlappingScrollBar(false)
horizontalScrollBar.preferredSize = Dimension(0, JBUI.scale(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT))
horizontalScrollBar.isOpaque = opts.opaque
verticalScrollBar.preferredSize = JBUI.emptySize()
}
}
private fun tableHeight(pane: JBScrollPane, inner: JComponent): Int {
val pad = pane.viewportBorder?.getBorderInsets(pane) ?: JBUI.emptyInsets()
return inner.preferredSize.height + pane.insets.top + pane.insets.bottom +
pad.top + pad.bottom + pane.horizontalScrollBar.preferredSize.height
}
private fun codeWidth(component: JComponent, text: String): Int {
val metrics = component.getFontMetrics(component.font)
val width = text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0
return width + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING)
}
private fun codeHeight(component: JComponent, text: String): Int {
private fun codeHeight(component: JComponent, text: String, max: Int?): Int {
val count = text.lineSequence().count()
val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS)
val base = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS)
val rows = max?.let { base.coerceAtMost(it) } ?: base
val field = component as? CodeField
if (field != null) {
field.ensureWillComputePreferredSize()
val ed = field.getEditor(false)
val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height
if (max != null) return line * rows
return maxOf(field.preferredSize.height, line * rows)
}
val line = component.getFontMetrics(component.font).height
return line * rows
}
private fun visibleCodeHeight(component: JComponent, text: String): Int {
val max = code.opts.maxLines ?: return component.preferredSize.height
val count = text.lineSequence().count()
val rows = count.coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS).coerceAtMost(max)
val field = component as? CodeField
if (field != null) {
field.ensureWillComputePreferredSize()
val ed = field.getEditor(false)
val line = ed?.lineHeight ?: component.getFontMetrics(component.font).height
return line * rows
}
val line = component.getFontMetrics(component.font).height
return line * rows
}
private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = object : JBTextArea(text.trimEnd('\n')), SessionCopyTarget {
override val copyAnchor: JComponent get() = this
@@ -705,21 +632,7 @@ internal open class MdViewHybrid(
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 = opts.preBg
ed.scrollPane.background = opts.preBg
ed.scrollPane.isOpaque = true
ed.scrollPane.viewport.isOpaque = true
ed.scrollPane.viewport.background = opts.preBg
ed.settings.isUseSoftWraps = soft
ed.settings.isAdditionalPageAtBottom = false
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
}
addSettingsProvider { ed -> applyEditorChrome(ed, opts, soft) }
}
override fun uiDataSnapshot(sink: DataSink) {
@@ -734,6 +647,35 @@ internal open class MdViewHybrid(
}
}
private open inner class CodePane(component: JComponent) : JBScrollPane(component) {
override fun doLayout() {
super.doLayout()
if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return
val view = viewport.view ?: return
val size = viewport.extentSize
if (size.height <= 0 || view.height == size.height) return
view.setSize(view.width.coerceAtLeast(size.width), size.height)
}
}
private inner class Override<T>(private val base: () -> T) {
var value: T? = null
private set
operator fun getValue(ref: Any?, property: KProperty<*>): T = value ?: base()
operator fun setValue(ref: Any?, property: KProperty<*>, next: T) {
if (disposed) return
if (value == next) return
value = next
syncStyle()
}
fun clear() {
value = null
}
}
private fun shellDisplay(term: Term, mode: Mode): ShellDisplay {
if (mode == Mode.Shell) return MdShellHighlight.project(term.text)
if (mode == Mode.Command) return MdShellHighlight.command(term.text)
@@ -810,17 +752,17 @@ internal open class MdViewHybrid(
private fun opts(): MdStyle {
val base = MdCommon.defaults(style)
return base.copy(
font = fontOverride ?: base.font,
foreground = foregroundOverride ?: base.foreground,
background = backgroundOverride ?: base.background,
linkColor = linkColorOverride ?: base.linkColor,
codeBg = codeBgOverride ?: base.codeBg,
preBg = preBgOverride ?: base.preBg,
preFg = preFgOverride ?: base.preFg,
codeFont = codeFontOverride ?: base.codeFont,
quoteBorder = quoteBorderOverride ?: base.quoteBorder,
quoteFg = quoteFgOverride ?: base.quoteFg,
tableBorder = tableBorderOverride ?: base.tableBorder,
font = fontOverride.value ?: base.font,
foreground = foregroundOverride.value ?: base.foreground,
background = backgroundOverride.value ?: base.background,
linkColor = linkColorOverride.value ?: base.linkColor,
codeBg = codeBgOverride.value ?: base.codeBg,
preBg = preBgOverride.value ?: base.preBg,
preFg = preFgOverride.value ?: base.preFg,
codeFont = codeFontOverride.value ?: base.codeFont,
quoteBorder = quoteBorderOverride.value ?: base.quoteBorder,
quoteFg = quoteFgOverride.value ?: base.quoteFg,
tableBorder = tableBorderOverride.value ?: base.tableBorder,
opaque = opaqueState,
)
}
@@ -836,157 +778,8 @@ internal open class MdViewHybrid(
return html
}
private fun collect(doc: Node): List<Desc> {
val visitor = Visitor()
doc.accept(visitor)
return visitor.blocks
}
private fun project(text: String): Projection {
val blocks = mutableListOf<Desc>()
val html = StringBuilder()
val md = StringBuilder()
val lines = lines(text)
var trailing: Fence? = null
var idx = 0
fun flush() {
if (md.isEmpty()) return
val doc = parser.parse(md.toString())
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()
}
while (idx < lines.size) {
val line = lines[idx]
val open = opener(line.text)
if (open == null) {
val pending = idx == lines.lastIndex && pendingOpener(line.text)
if (pending) {
flush()
blocks.add(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE)))
html.append(codeHtml(""))
} else {
md.append(line.text).append(line.end)
}
idx++
continue
}
flush()
idx++
val code = StringBuilder()
var closed = false
var trimmed = false
while (idx < lines.size) {
val item = lines[idx]
val close = closer(item.text, open)
if (close) {
closed = true
idx++
break
}
val partial = idx == lines.lastIndex && partialCloser(item.text, open)
if (partial) trimmed = true
if (!partial) code.append(item.text).append(item.end)
idx++
}
val desc = Desc.Code(code.toString(), MdLanguage.kind(open.info))
blocks.add(desc)
html.append(codeHtml(desc.text))
trailing = if (!closed && !trimmed) open else null
}
flush()
return Projection(html.toString(), blocks, trailing)
}
private fun lines(text: String): List<Line> {
if (text.isEmpty()) return emptyList()
val lines = mutableListOf<Line>()
var start = 0
while (start < text.length) {
val end = text.indexOf('\n', start)
if (end == -1) {
lines.add(Line(text.substring(start), ""))
break
}
lines.add(Line(text.substring(start, end), "\n"))
start = end + 1
}
return lines
}
private fun opener(text: String): Fence? {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return null
val char = trimmed.firstOrNull() ?: return null
if (char != '`' && char != '~') return null
val size = trimmed.takeWhile { it == char }.length
if (size < 3) return null
val info = trimmed.drop(size).trim()
if (char == '`' && info.contains('`')) return null
return Fence(char, size, info)
}
private fun closer(text: String, fence: Fence): Boolean {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return false
val size = trimmed.takeWhile { it == fence.char }.length
if (size < fence.size) return false
return trimmed.drop(size).isBlank()
}
private fun pendingOpener(text: String): Boolean {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return false
val char = trimmed.firstOrNull() ?: return false
if (char != '`' && char != '~') return false
val size = trimmed.takeWhile { it == char }.length
if (size !in 1..2) return false
return trimmed.drop(size).isBlank()
}
private fun partialCloser(text: String, fence: Fence): Boolean {
val trimmed = text.dropWhile { it == ' ' }
val indent = text.length - trimmed.length
if (indent > 3) return false
val size = trimmed.takeWhile { it == fence.char }.length
if (size !in 1 until fence.size) return false
return trimmed.drop(size).isBlank()
}
private fun codeHtml(text: String): String = "<pre><code>${escape(text)}</code></pre>\n"
private fun escape(text: String): String = text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private sealed class Desc {
data class Html(val body: String) : Desc()
data class Code(val text: String, val kind: Kind) : Desc()
}
private data class Projection(val html: String, val blocks: List<Desc>, val open: Fence?)
private data class HtmlCache(val body: String, val color: Int, val html: String)
private data class Line(val text: String, val end: String)
private data class Fence(val char: Char, val size: Int, val info: String)
private abstract inner class View(
var desc: Desc,
val component: JComponent,
@@ -1017,6 +810,29 @@ internal open class MdViewHybrid(
}
}
private inner class TableView(desc: Desc.Table, private val pane: JBScrollPane, disposable: Disposable) :
View(desc, pane, disposable) {
override fun compatible(desc: Desc) = desc is Desc.Table
override fun update(desc: Desc) {
if (this.desc == desc) return
this.desc = desc
val inner = pane.viewport.view as? JBHtmlPane ?: return
inner.text = html((desc as Desc.Table).body, opts())
pane.revalidate()
}
override fun style(opts: MdStyle) {
styleTablePane(pane, opts)
val inner = pane.viewport.view as? JBHtmlPane ?: return
inner.isOpaque = opts.opaque
inner.background = opts.background
inner.reloadCssStylesheets()
inner.text = html((desc as Desc.Table).body, opts)
pane.revalidate()
}
}
private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) :
View(desc, pane, disposable) {
override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind
@@ -1038,18 +854,7 @@ internal open class MdViewHybrid(
override fun grow(delta: String) {
val item = desc as Desc.Code
val next = item.copy(text = item.text + delta)
desc = next
val value = next.text.trimEnd('\n')
val view = pane.viewport.view
when (view) {
is CodeField -> view.text = value
is JBTextArea -> view.text = value
}
if (view is JComponent) {
sizeCodeField(view, value)
sizeCodePane(pane, view)
}
update(item.copy(text = item.text + delta))
}
override fun style(opts: MdStyle) {
@@ -1059,29 +864,12 @@ internal open class MdViewHybrid(
is CodeField -> {
view.font = style.editorFont
view.background = opts.preBg
view.getEditor(false)?.let { ed ->
style.applyToEditor(ed)
ed.setBorder(JBUI.Borders.empty())
ed.scrollPane.border = JBUI.Borders.empty()
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
ed.backgroundColor = opts.preBg
ed.scrollPane.background = opts.preBg
ed.scrollPane.isOpaque = true
ed.scrollPane.viewport.isOpaque = true
ed.scrollPane.viewport.background = opts.preBg
ed.settings.isUseSoftWraps = view.soft
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
}
view.getEditor(false)?.let { ed -> applyEditorChrome(ed, opts, view.soft) }
}
is JBTextArea -> styleTextArea(view, opts)
}
if (view is JComponent) {
val text = when (view) {
is CodeField -> view.text
is JBTextArea -> view.text
else -> ""
}
val text = fieldText(view)
sizeCodeField(view, text)
sizeCodePane(pane, view)
}
@@ -1113,20 +901,7 @@ internal open class MdViewHybrid(
val kind = item.kind as Kind.Terminal
view.font = style.editorFont
view.background = opts.preBg
view.getEditor(false)?.let { ed ->
style.applyToEditor(ed)
ed.setBorder(JBUI.Borders.empty())
ed.scrollPane.border = JBUI.Borders.empty()
ed.scrollPane.viewportBorder = JBUI.Borders.empty()
ed.backgroundColor = opts.preBg
ed.scrollPane.background = opts.preBg
ed.scrollPane.isOpaque = true
ed.scrollPane.viewport.isOpaque = true
ed.scrollPane.viewport.background = opts.preBg
ed.settings.isUseSoftWraps = view.soft
ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
}
view.getEditor(false)?.let { ed -> applyEditorChrome(ed, opts, view.soft) }
val term = MdTerminal.decode(item.text, kind.stream)
val value = shellDisplay(term, kind.mode)
if (view.text != value.text) view.text = value.text
@@ -1140,44 +915,4 @@ internal open class MdViewHybrid(
update(item.copy(text = item.text + delta))
}
}
private inner class Visitor : AbstractVisitor() {
val blocks = mutableListOf<Desc>()
private val run = StringBuilder()
override fun visit(document: Document) {
visitChildren(document)
flush()
}
override fun visit(code: FencedCodeBlock) {
flush()
blocks.add(Desc.Code(code.literal, MdLanguage.kind(code.info)))
}
override fun visit(code: IndentedCodeBlock) {
flush()
blocks.add(Desc.Code(code.literal, MdLanguage.kind(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 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
}
}
}
}
@@ -93,6 +93,7 @@ session.part.reasoning=Reasoning
session.part.compaction=context compacted
session.part.tool.copy=Copy
session.part.tool.error=Error
session.part.tool.agent={0} Agent
session.part.tool.pending=Pending
session.part.tool.read=Read
session.part.tool.glob=Glob
@@ -1,12 +1,17 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.model.Tool
import ai.kilocode.rpc.dto.AgentDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelDto
import ai.kilocode.rpc.dto.PartDto
import ai.kilocode.rpc.dto.PartSourceDto
@@ -23,6 +28,8 @@ import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.ToolRefDto
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.onCompletion
class PromptLifecycleTest : SessionControllerTestBase() {
@@ -626,6 +633,122 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertTrue("Root state must not be changed by child non-permission events", stateEvents.isEmpty())
}
fun `test child tool update is stored on parent task part`() {
val (m, _, modelEvents) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")))
val task = m.model.content("msg1", "part_task") as Tool
assertEquals("ses_child", task.childSessionId)
assertEquals(1, task.childTools.size)
assertEquals("read", task.childTools[0].name)
assertNull(m.model.content("child_msg", "child_read"))
assertTrue(modelEvents.any { it.toString() == "ContentUpdated msg1/part_task" })
}
fun `test child tool removed updates parent task part`() {
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")), flush = false)
emit(ChatEventDto.PartRemoved("ses_child", "child_msg", "child_read"))
val task = m.model.content("msg1", "part_task") as Tool
assertTrue(task.childTools.isEmpty())
}
fun `test history load backfills child tools`() {
appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
rpc.histories["ses_test"] = mutableListOf(
MessageWithPartsDto(
msg("msg1", "ses_test", "assistant"),
listOf(taskPart("ses_child").part),
),
)
rpc.histories["ses_child"] = mutableListOf(
MessageWithPartsDto(
msg("child_msg", "ses_child", "assistant"),
listOf(childTool("child_read", "read"), childTool("child_grep", "grep")),
),
)
val m = controller("ses_test")
flush()
val task = m.model.content("msg1", "part_task") as Tool
assertEquals(listOf("read", "grep"), task.childTools.map { it.name })
assertNull(m.model.content("child_msg", "child_read"))
}
fun `test stale child history does not overwrite live child tool update`() {
rpc.historyGate = CompletableDeferred()
rpc.histories["ses_child"] = mutableListOf(
MessageWithPartsDto(
msg("child_msg", "ses_child", "assistant"),
listOf(childTool("child_read", "grep")),
),
)
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")))
var task = m.model.content("msg1", "part_task") as Tool
assertEquals(listOf("read"), task.childTools.map { it.name })
rpc.historyGate!!.complete(Unit)
flush()
task = m.model.content("msg1", "part_task") as Tool
assertEquals(listOf("read"), task.childTools.map { it.name })
}
fun `test stale child history does not resurrect live removed child tool`() {
rpc.historyGate = CompletableDeferred()
rpc.histories["ses_child"] = mutableListOf(
MessageWithPartsDto(
msg("child_msg", "ses_child", "assistant"),
listOf(childTool("child_read", "read")),
),
)
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"), flush = false)
emit(ChatEventDto.PartUpdated("ses_child", childTool("child_read", "read")), flush = false)
emit(ChatEventDto.PartRemoved("ses_child", "child_msg", "child_read"))
var task = m.model.content("msg1", "part_task") as Tool
assertTrue(task.childTools.isEmpty())
rpc.historyGate!!.complete(Unit)
flush()
task = m.model.content("msg1", "part_task") as Tool
assertTrue(task.childTools.isEmpty())
}
fun `test task child rekey cancels old child subscription`() {
val closed = CopyOnWriteArrayList<String>()
rpc.eventFlow = { id, _ -> rpc.events.onCompletion { closed.add(id) } }
val (m, _, _) = prompted()
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
emit(taskPart("ses_child"))
emit(taskPart("ses_new"))
settle()
assertTrue("closed=$closed", closed.contains("ses_child"))
emit(ChatEventDto.PermissionAsked("ses_child", childPermission("old_perm", "ses_child")), flush = false)
emit(ChatEventDto.PermissionAsked("ses_new", childPermission("new_perm", "ses_new")))
assertTrue(m.model.state is SessionState.AwaitingPermission)
val perm = (m.model.state as SessionState.AwaitingPermission).permission
assertEquals("new_perm", perm.id)
assertEquals("ses_new", perm.sessionId)
}
fun `test root permission event is not processed as child permission`() {
val (m, _, _) = prompted()
@@ -646,12 +769,23 @@ class PromptLifecycleTest : SessionControllerTestBase() {
type = "tool",
tool = "task",
metadata = mapOf("sessionId" to childSessionId),
input = mapOf("subagent_type" to "explore", "description" to "Find files"),
),
)
private fun childPermission(id: String) = PermissionRequestDto(
private fun childTool(id: String, name: String) = PartDto(
id = id,
sessionID = "ses_child",
messageID = "child_msg",
type = "tool",
tool = name,
state = "completed",
input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"),
)
private fun childPermission(id: String, sid: String = "ses_child") = PermissionRequestDto(
id = id,
sessionID = sid,
permission = "edit",
patterns = listOf("*.kt"),
always = emptyList(),
@@ -358,6 +358,62 @@ class SessionModelTest : BasePlatformTestCase() {
assertTrue(events.single() is SessionModelEvent.ContentUpdated)
}
fun `test updateContent task rekeys child tracking when session id changes`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", taskPart("task", "m1", "child_old"))
model.upsertChildTool("child_old", childPart("read_old"))
assertEquals("read_old", task("m1", "task").childTools.single().id)
model.updateContent("m1", taskPart("task", "m1", "child_new"))
assertTrue(task("m1", "task").childTools.isEmpty())
model.upsertChildTool("child_old", childPart("read_stale"))
assertTrue(task("m1", "task").childTools.isEmpty())
model.upsertChildTool("child_new", childPart("read_new"))
assertEquals("read_new", task("m1", "task").childTools.single().id)
}
fun `test stale child history cannot resurrect removed child tool`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", taskPart("task", "m1", "child"))
model.removeChildTool("child", "read_old")
model.upsertChildTool("child", childPart("read_old"), replace = false)
assertTrue(task("m1", "task").childTools.isEmpty())
model.upsertChildTool("child", childPart("read_old"))
assertEquals("read_old", task("m1", "task").childTools.single().id)
}
fun `test removeContent untracks child tools`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", taskPart("task", "m1", "child"))
model.upsertChildTool("child", childPart("read_old"))
model.removeContent("m1", "task")
events.clear()
model.upsertChildTool("child", childPart("read_new"))
assertNull(model.content("m1", "task"))
assertTrue(events.isEmpty())
}
fun `test removeMessage untracks child tools`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", taskPart("task", "m1", "child"))
model.upsertChildTool("child", childPart("read_old"))
model.removeMessage("m1")
events.clear()
model.upsertChildTool("child", childPart("read_new"))
assertNull(model.message("m1"))
assertTrue(events.isEmpty())
}
fun `test updateContent tool updates rich fields`() {
model.addMessage(msg("m1", "assistant"))
model.updateContent("m1", part("p1", "m1", "tool", tool = "bash", state = "pending"))
@@ -1056,6 +1112,24 @@ class SessionModelTest : BasePlatformTestCase() {
source = source,
)
private fun taskPart(id: String, mid: String, child: String) = part(
id = id,
mid = mid,
type = "tool",
tool = "task",
metadata = mapOf("sessionId" to child),
)
private fun childPart(id: String) = part(
id = id,
mid = "child_msg",
type = "tool",
tool = "read",
input = mapOf("filePath" to "src/Main.kt"),
)
private fun task(mid: String, id: String) = model.content(mid, id) as Tool
private fun question(id: String) = Question(
id = id,
items = listOf(
@@ -52,23 +52,31 @@ import com.intellij.openapi.editor.actions.PasteAction
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.fileEditor.TextEditor
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.PlainTextLanguage
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.AnimatedIcon
import com.intellij.ui.EditorTextField
import com.intellij.ui.LanguageTextField
import com.intellij.ui.components.JBLabel
import com.intellij.util.Producer
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import java.awt.Container
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Container
import java.awt.DefaultKeyboardFocusManager
import java.awt.KeyboardFocusManager
import java.awt.datatransfer.DataFlavor
import java.awt.datatransfer.StringSelection
import java.awt.datatransfer.Transferable
@@ -84,6 +92,8 @@ import javax.swing.ImageIcon
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities
private const val FLOATING = "com.intellij.openapi.editor.toolbar.floating.EditorFloatingToolbar"
@Suppress("UnstableApiUsage")
class PromptPanelTest : BasePlatformTestCase() {
private val roots = mutableListOf<SessionRootPanel>()
@@ -108,13 +118,13 @@ class PromptPanelTest : BasePlatformTestCase() {
}
}
fun `test prompt input uses editor font settings`() {
fun `test prompt input uses transcript font settings`() {
val style = SessionEditorStyle.current()
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val font = panel.inputFont()
assertEquals(style.editorFamily, font.name)
assertEquals(style.editorSize, font.size)
assertEquals(style.transcriptFont.name, font.name)
assertEquals(style.transcriptFont.size, font.size)
}
fun `test prompt input uses editor background`() {
@@ -124,14 +134,51 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background)
}
fun `test prompt editor hides floating toolbar`() {
val control = toolbarControl()
realize(control, 260, 400)
UIUtil.dispatchAllInvocationEvents()
assertTrue(hasFloatingToolbar(control.getEditor(false)!!.component))
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
realize(panel, 260, 400)
UIUtil.dispatchAllInvocationEvents()
val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!!
assertFalse(hasFloatingToolbar(editor.component))
}
fun `test prompt focus outline follows editor focus`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
realize(panel, 260, 400)
panel.setBounds(0, 0, 260, panel.preferredSize.height)
panel.doLayout()
val editor = (panel.defaultFocusedComponent as EditorTextField).getEditor(false)!!
val current = KeyboardFocusManager.getCurrentKeyboardFocusManager()
val focus = TestFocusManager()
KeyboardFocusManager.setCurrentKeyboardFocusManager(focus)
try {
assertTrue(JBUI.CurrentTheme.Focus.focusColor().rgb != paint(panel, panel.width / 2, 1).rgb)
focus.focus(editor.contentComponent)
assertEquals(JBUI.CurrentTheme.Focus.focusColor().rgb, paint(panel, panel.width / 2, 1).rgb)
} finally {
KeyboardFocusManager.setCurrentKeyboardFocusManager(current)
}
}
fun `test applyStyle updates prompt input and height`() {
val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> })
val style = SessionEditorStyle.create(family = "Courier New", size = 26)
panel.applyStyle(style)
assertEquals("Courier New", panel.inputFont().name)
assertEquals(26, panel.inputFont().size)
assertEquals(style.transcriptFont.name, panel.inputFont().name)
assertEquals(style.transcriptFont.size, panel.inputFont().size)
assertTrue(panel.preferredSize.height >= 26)
}
@@ -570,6 +617,33 @@ class PromptPanelTest : BasePlatformTestCase() {
assertEquals(listOf(part), sent)
}
fun `test cancelling submit mention resolution re-enables send`() {
val entered = CompletableDeferred<Unit>()
val gate = CompletableDeferred<Unit>()
val panel = PromptPanel(
project = project,
onSend = { _, _ -> },
onAbort = {},
onEnhance = { _, _ -> },
onMentions = {
entered.complete(Unit)
gate.await()
emptyList()
},
cs = scope,
)
val editor = panel.defaultFocusedComponent as EditorTextField
panel.setReady(true)
editor.text = "send @file"
panel.send()
waitForSend { entered.isCompleted && !panel.isSendEnabled }
scope.cancel(CancellationException("test cancellation"))
waitForSend { panel.isSendEnabled }
assertTrue(panel.isSendEnabled)
}
fun `test clear removes attachments`() {
val panel = PromptPanel(project, { _, _ -> }, {}, { _, _ -> })
@@ -1051,7 +1125,7 @@ class PromptPanelTest : BasePlatformTestCase() {
return out
}
private fun realize(panel: PromptPanel, width: Int, height: Int): SessionRootPanel {
private fun realize(panel: Component, width: Int, height: Int): SessionRootPanel {
val root = SessionRootPanel()
root.setSize(width, height)
root.content.add(JPanel(BorderLayout()).apply { add(panel, BorderLayout.SOUTH) }, BorderLayout.CENTER)
@@ -1063,6 +1137,27 @@ class PromptPanelTest : BasePlatformTestCase() {
return root
}
private fun toolbarControl(): EditorTextField {
val doc = LanguageTextField.createDocument(
"",
PlainTextLanguage.INSTANCE,
project,
LanguageTextField.SimpleDocumentCreator(),
)
return EditorTextField(doc, project, PlainTextFileType.INSTANCE, false, false)
}
private fun paint(component: Component, x: Int, y: Int): Color {
val image = BufferedImage(component.width, component.height, BufferedImage.TYPE_INT_ARGB)
val g = image.createGraphics()
try {
component.paint(g)
} finally {
g.dispose()
}
return Color(image.getRGB(x, y), true)
}
private fun completion() = KiloPromptCompletionProvider(
workspace = workspaces.workspace("/test"),
service = workspaces,
@@ -1154,6 +1249,12 @@ class PromptPanelTest : BasePlatformTestCase() {
}
}
private fun hasFloatingToolbar(component: Component): Boolean {
if (component.javaClass.name == FLOATING) return true
if (component !is Container) return false
return component.components.any(::hasFloatingToolbar)
}
private fun createEditor(): Editor {
val factory = EditorFactory.getInstance()
return factory.createEditor(factory.createDocument(""), project)
@@ -1217,6 +1318,12 @@ class PromptPanelTest : BasePlatformTestCase() {
}
}
private class TestFocusManager : DefaultKeyboardFocusManager() {
fun focus(component: Component) {
setGlobalFocusOwner(component)
}
}
private class TestSink : CopyProviderSink() {
var send: Any? = null
var file: Any? = null
@@ -2,6 +2,8 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Font
@@ -98,4 +100,13 @@ class SessionEditorStyleTest : BasePlatformTestCase() {
assertFalse("boldFont should not use editor font family", style.boldFont.name == "Courier New")
assertFalse("smallFont should not use editor font family", style.smallFont.name == "Courier New")
}
fun `test transcript editor styling ignores disposed editor`() {
val factory = EditorFactory.getInstance()
val editor = factory.createEditor(factory.createDocument(""), project) as EditorEx
factory.releaseEditor(editor)
SessionEditorStyle.current().applyTranscriptToEditor(editor)
}
}
@@ -20,8 +20,10 @@ import ai.kilocode.client.session.views.MessageToolbar
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.TextView
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.session.views.todo.TodoWriteView
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.rpc.dto.MessageDto
import ai.kilocode.rpc.dto.MessageTimeDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
@@ -30,6 +32,8 @@ import ai.kilocode.rpc.dto.TodoDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
@@ -376,6 +380,34 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(mv.partIds().isEmpty())
}
fun `test child tool update refreshes collapsed task view without replacing it`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent(
"a1",
toolPart(
"part_task",
"a1",
"task",
"call_task",
input = mapOf("subagent_type" to "explore", "description" to "Find files"),
metadata = mapOf("sessionId" to "ses_child"),
),
)
model.upsertChildTool("ses_child", childTool("child_read", "read"))
val view = panel.findMessage("a1")!!.part("part_task") as TaskToolView
assertTrue(view.isExpanded())
view.collapse()
model.upsertChildTool("ses_child", childTool("child_read", "grep"))
val updated = panel.findMessage("a1")!!.part("part_task") as TaskToolView
assertSame(view, updated)
assertFalse(updated.isExpanded())
updated.expand()
assertTrue(taskText(updated).single().contains("Grep"))
assertTrue(taskText(updated).single().contains("pattern=query"))
}
// ------ HistoryLoaded ------
fun `test HistoryLoaded rebuilds panel from scratch`() {
@@ -804,6 +836,16 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
input = input, metadata = metadata, todos = todos,
)
private fun childTool(id: String, tool: String) = PartDto(
id = id,
sessionID = "ses_child",
messageID = "child_msg",
type = "tool",
tool = tool,
state = "completed",
input = mapOf("filePath" to "src/Main.kt", "pattern" to "query"),
)
private fun root(view: QuestionResultView) = view.components[0] as JPanel
private fun header(view: QuestionResultView) = root(view).components[0] as JPanel
@@ -857,4 +899,14 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
visit(root)
return out
}
private fun taskText(view: TaskToolView): List<String> {
val scroll = components(view).filterIsInstance<JBScrollPane>().single()
val stack = components(scroll.viewport.view).filterIsInstance<Stack>().single()
return stack.components.map { row ->
components(row).filterIsInstance<JBLabel>()
.mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }
.joinToString(" ")
}
}
}
@@ -160,6 +160,24 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertTrue(mv.part("cp1") is ai.kilocode.client.session.views.CompactionView)
}
fun `test user compaction marker renders without prompt chrome`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", PartDto("cp1", "ses", "u1", "compaction"))
val mv = panel.findMessage("u1")!!
assertEquals(SessionView.Kind.Default, mv.sessionViewKind)
assertEquals(listOf("cp1"), mv.partIds())
assertTrue(mv.part("cp1") is ai.kilocode.client.session.views.CompactionView)
}
fun `test user text message keeps prompt chrome`() {
model.upsertMessage(msg("u1", "user"))
model.updateContent("u1", part("p1", "u1", "text", text = "hello"))
val mv = panel.findMessage("u1")!!
assertEquals(SessionView.Kind.UserPrompt, mv.sessionViewKind)
}
// ------ generic fallback ------
fun `test unknown part type falls back to GenericView`() {
@@ -401,6 +401,22 @@ class ShellToolViewTest : BasePlatformTestCase() {
assertEquals(1, editors.size)
assertEquals("echo one;\n echo two;\n echo three", editors.single().text)
val pane = popupScrollPanes(body.component).single { it.viewport.view is com.intellij.ui.EditorTextField }
val pad = pane.viewportBorder.getBorderInsets(pane)
val field = editors.single()
val border = field.border.getBorderInsets(field)
val editor = field.getEditor(true)!!
val lines = field.text.lines().size
assertEquals(
SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING,
pad.top,
)
assertEquals(SessionUiStyle.View.Code.VIEWPORT_BOTTOM_PADDING, pad.bottom)
assertEquals(SessionUiStyle.View.Code.SCROLLBAR_HEIGHT, border.top)
assertEquals(0, border.bottom)
assertTrue(field.preferredSize.height - border.top >= editor.lineHeight * lines)
assertTrue(field.minimumSize.height - border.top >= editor.lineHeight * lines)
assertTrue(pane.preferredSize.height >= field.preferredSize.height + pad.top + pad.bottom)
assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH))
assertTrue(body.component.preferredSize.height > 0)
} finally {
@@ -527,4 +543,14 @@ class ShellToolViewTest : BasePlatformTestCase() {
visit(root)
return found
}
private fun popupScrollPanes(root: JComponent): List<JBScrollPane> {
val found = mutableListOf<JBScrollPane>()
fun visit(component: JComponent) {
if (component is JBScrollPane) found.add(component)
component.components.filterIsInstance<JComponent>().forEach(::visit)
}
visit(root)
return found
}
}
@@ -0,0 +1,89 @@
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.TaskToolView
import ai.kilocode.client.ui.layout.Stack
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.UIUtil
import java.awt.Component
import java.awt.Container
@Suppress("UnstableApiUsage")
class TaskToolViewStressTest : BasePlatformTestCase() {
private val views = mutableListOf<TaskToolView>()
override fun tearDown() {
try {
views.forEach(Disposer::dispose)
views.clear()
} finally {
super.tearDown()
}
}
fun `test child tool churn retains rows and stays bounded`() {
val base = EditorFactory.getInstance().allEditors.size
val view = view(task(children = children(3)))
val first = rows(view)[0]
val second = rows(view)[1]
repeat(120) { i ->
val count = 4 + i % 25
view.update(task(children = children(count)))
assertSame(first, rows(view)[0])
assertSame(second, rows(view)[1])
assertEquals(count, rows(view).size)
}
repeat(80) { i ->
val ids = listOf("c1", "c2") + (4..(8 + i % 10)).map { "c$it" }
view.update(task(children = ids.map { child(it, if (it == "c2" && i % 2 == 0) "grep" else "read") }))
assertSame(first, rows(view)[0])
assertSame(second, rows(view)[1])
assertEquals(ids.size, rows(view).size)
}
view.collapse()
drainEdt()
assertFalse(view.isExpanded())
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) }
private fun task(children: List<Tool> = emptyList()) = Tool("part_task", "task", toolKind("task")).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("subagent_type" to "explore", "description" to "Find files")
it.metadata = mapOf("sessionId" to "ses_child")
it.childSessionId = "ses_child"
it.childTools = children
}
private fun child(id: String, name: String) = Tool(id, name, toolKind(name)).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("filePath" to "src/Main.kt", "pattern" to "query")
}
private fun children(count: Int) = (1..count).map { child("c$it", "read") }
private fun rows(view: TaskToolView): List<Component> {
val scroll = descendants(view).filterIsInstance<JBScrollPane>().single()
val stack = descendants(scroll.viewport.view).filterIsInstance<Stack>().single()
return stack.components.toList()
}
private fun descendants(root: Component): List<Component> {
if (root !is Container) return emptyList()
return root.components.flatMap { child -> listOf(child) + descendants(child) }
}
private fun drainEdt() {
UIUtil.dispatchAllInvocationEvents()
}
}
@@ -0,0 +1,205 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.session.views.tool.TaskToolView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
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 java.awt.Color
import javax.swing.JComponent
import javax.swing.ScrollPaneConstants
@Suppress("UnstableApiUsage")
class TaskToolViewTest : BasePlatformTestCase() {
private val views = mutableListOf<TaskToolView>()
override fun tearDown() {
try {
views.forEach(Disposer::dispose)
views.clear()
} finally {
super.tearDown()
}
}
fun `test task tool uses secondary chrome`() {
val base: Any = view(task())
assertTrue(base is SecondarySessionPartView)
}
fun `test task header shows agent description and count`() {
val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep"))))
assertTrue(view.dumpLabel().contains("Explore Agent"))
assertTrue(view.dumpLabel().contains("Find files (2)"))
assertEquals(2, rows(view).size)
assertTrue(view.isExpanded())
}
fun `test update adds child row without replacing existing rows`() {
val view = view(task(children = listOf(child("c1", "read"))))
val before = rowText(view).first()
view.update(task(children = listOf(child("c1", "read"), child("c2", "grep"))))
assertEquals(2, rows(view).size)
assertEquals(before, rowText(view).first())
assertTrue(rowText(view).any { it.contains("Grep") })
}
fun `test removing child rows collapses body`() {
val view = view(task(children = listOf(child("c1", "read"))))
view.update(task(children = emptyList()))
assertFalse(view.isExpanded())
assertNull(scroll(view))
}
fun `test body is lazy until child tools arrive`() {
val view = view(task(children = emptyList()))
assertNull(scroll(view))
view.update(task(children = listOf(child("c1", "read"))))
assertNotNull(scroll(view))
assertTrue(view.isExpanded())
}
fun `test collapsed task body stays collapsed on child update`() {
val view = view(task(children = listOf(child("c1", "read"))))
view.collapse()
view.update(task(children = listOf(child("c1", "grep"))))
assertFalse(view.isExpanded())
view.expand()
assertTrue(rowText(view).single().contains("Grep"))
assertTrue(rowText(view).single().contains("pattern=query"))
}
fun `test expanded task body is capped to ten rows`() {
val view = view(task(children = children(20)))
val taller = view(task(children = children(80)))
assertEquals(10, SessionUiStyle.View.Tool.TASK_LINES)
assertTrue(view.preferredSize.height > 0)
assertEquals(view.preferredSize.height, taller.preferredSize.height)
}
fun `test task body uses nested vertical scroll`() {
val view = view(task(children = children(20)))
val scroll = scroll(view)!!
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, scroll.horizontalScrollBarPolicy)
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, scroll.verticalScrollBarPolicy)
}
fun `test child tool titles use target color`() {
val view = view(task(children = listOf(child("c1", "read"), child("c2", "grep", ToolExecState.ERROR))))
assertColor(UiStyle.Colors.weak(), titleColor(view, 0))
assertColor(UiStyle.Colors.errorLabelForeground(), titleColor(view, 1))
}
fun `test task body is indented beyond header padding`() {
val view = view(task(children = listOf(child("c1", "read"))))
val insets = body(view).border.getBorderInsets(body(view))
assertTrue(insets.left > JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING))
assertEquals(UiStyle.Gap.sm(), insets.top)
assertEquals(UiStyle.Gap.sm(), insets.bottom)
}
fun `test appended child tools scroll nested body to bottom`() {
val view = view(task(children = children(40)))
view.setSize(300, view.preferredSize.height)
view.doLayout()
UIUtil.dispatchAllInvocationEvents()
val scroll = scroll(view)!!
scroll.verticalScrollBar.value = bottom(scroll) - 1
view.update(task(children = children(70)))
UIUtil.dispatchAllInvocationEvents()
UIUtil.dispatchAllInvocationEvents()
UIUtil.dispatchAllInvocationEvents()
assertEquals(bottom(scroll), scroll.verticalScrollBar.value)
}
fun `test appended child tools do not yank nested body above tail`() {
val view = view(task(children = children(40)))
view.setSize(300, view.preferredSize.height)
view.doLayout()
UIUtil.dispatchAllInvocationEvents()
val scroll = scroll(view)!!
scroll.verticalScrollBar.value = 0
view.update(task(children = children(70)))
UIUtil.dispatchAllInvocationEvents()
assertEquals(0, scroll.verticalScrollBar.value)
}
private fun view(tool: Tool): TaskToolView = TaskToolView(tool).also { views.add(it) }
private fun task(children: List<Tool> = emptyList()) = Tool("part_task", "task", toolKind("task")).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("subagent_type" to "explore", "description" to "Find files")
it.metadata = mapOf("sessionId" to "ses_child")
it.childSessionId = "ses_child"
it.childTools = children
}
private fun child(id: String, name: String, state: ToolExecState = ToolExecState.COMPLETED) = Tool(id, name, toolKind(name)).also {
it.state = state
it.input = mapOf("filePath" to "src/Main.kt", "pattern" to "query")
}
private fun children(count: Int) = (1..count).map { child("c$it", "read") }
private fun scroll(view: TaskToolView): JBScrollPane? = descendants(view).filterIsInstance<JBScrollPane>().singleOrNull()
private fun body(view: TaskToolView) = scroll(view)!!.viewport.view as JComponent
private fun rows(view: TaskToolView): List<Component> {
val stack = descendants(body(view)).filterIsInstance<Stack>().singleOrNull() ?: return emptyList()
return stack.components.toList()
}
private fun rowText(view: TaskToolView) = rows(view).map { row ->
descendants(row).filterIsInstance<JBLabel>().mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }.joinToString(" ")
}
private fun titleColor(view: TaskToolView, index: Int) = descendants(rows(view)[index])
.filterIsInstance<JBLabel>()
.first { it.text.isNotBlank() }
.foreground
private fun descendants(root: Component): List<Component> {
if (root !is Container) return emptyList()
return root.components.flatMap { child -> listOf(child) + descendants(child) }
}
private fun bottom(scroll: JBScrollPane): Int {
val view = scroll.viewport.view ?: return 0
return maxOf(0, view.height - scroll.viewport.extentSize.height)
}
private fun assertColor(expected: Color, actual: Color?) {
assertNotNull(actual)
assertEquals(expected.rgb, actual!!.rgb)
}
}
@@ -210,13 +210,13 @@ class TextViewTest : BasePlatformTestCase() {
assertEquals(style.editorForeground, view.md.foreground)
}
fun `test prompt view uses editor font and background`() {
fun `test prompt view uses transcript font and editor background`() {
val style = SessionEditorStyle.create(family = "Courier New", size = 23)
val view = PromptView(Text("p1"))
view.applyStyle(style)
assertEquals(style.editorFont, view.md.font)
assertEquals(style.transcriptFont, view.md.font)
assertEquals(style.editorBackground, view.md.background)
assertFalse(view.contentOpaque())
}
@@ -86,6 +86,19 @@ class TodoWriteViewTest : BasePlatformTestCase() {
assertEquals(UiStyle.Gap.md(), centerGap(view))
}
fun `test todo body uses next standard inner padding`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(TodoDto("Next", "pending", "medium"))
})
val body = view.components.filterIsInstance<TodoListPanel>().single()
val ins = body.border.getBorderInsets(body)
assertEquals(UiStyle.Gap.lg() + SessionUiStyle.View.Outline.width(), ins.top)
assertEquals(UiStyle.Gap.pad(), ins.left)
assertEquals(UiStyle.Gap.lg(), ins.bottom)
assertEquals(UiStyle.Gap.pad(), ins.right)
}
fun `test compact view renders hidden labels and visible rows`() {
val view = TodoWriteView(tool("todowrite", ToolExecState.COMPLETED).also {
it.todos = listOf(
@@ -286,9 +286,7 @@ class AgentsSettingsUiTest : BasePlatformTestCase() {
list.doLayout()
val idx = rows(panel).indexOfFirst { it.key == "hidden" }
list.selectedIndex = idx
val row = rows(panel)[idx]
val bounds = list.getCellBounds(idx, idx)
val area = settingsListCellBounds(list, bounds, row, selected = true).getValue(DELETE_CELL)
val area = settingsListCellBounds(list, idx, selected = true).getValue(DELETE_CELL)
click(list, center(area))
true
}
@@ -483,9 +481,7 @@ class AgentsSettingsUiTest : BasePlatformTestCase() {
list.doLayout()
val idx = rows(panel).indexOfFirst { it.key == key }
list.selectedIndex = idx
val row = rows(panel)[idx]
val bounds = list.getCellBounds(idx, idx)
val area = settingsListCellBounds(list, bounds, row, selected = true).getValue(cell)
val area = settingsListCellBounds(list, idx, selected = true).getValue(cell)
click(list, center(area))
}
@@ -350,9 +350,7 @@ class McpSettingsUiTest : BasePlatformTestCase() {
list.doLayout()
val idx = rows(panel).indexOfFirst { it.key == key }
list.selectedIndex = idx
val row = rows(panel)[idx]
val bounds = list.getCellBounds(idx, idx)
val area = settingsListCellBounds(list, bounds, row, selected = true).getValue(id)
val area = settingsListCellBounds(list, idx, selected = true).getValue(id)
click(list, center(area))
true
}
@@ -176,8 +176,7 @@ class SettingsListViewTest : BasePlatformTestCase() {
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val bounds = view.list.getCellBounds(0, 0)
val area = settingsListCellBounds(view.list, bounds, row, selected = true).getValue("edit")
val area = settingsListCellBounds(view.list, 0, selected = true).getValue("edit")
val point = Point(area.x + area.width - 1, area.y + area.height - 1)
click(view, point)
@@ -186,6 +185,17 @@ class SettingsListViewTest : BasePlatformTestCase() {
}
}
fun `test action hit test ignores stale indexes`() {
edt {
val view = SettingsListView("Empty") { _, _ -> }
view.update(listOf(item("with", "Alpha", null, SettingsListCell("edit", "Edit"))))
layout(view)
assertNull(settingsListCellAt(view.list, -1, Point(0, 0), selected = true))
assertNull(settingsListCellAt(view.list, view.list.model.size, Point(0, 0), selected = true))
}
}
fun `test double click invokes primary cell instead of first visual cell`() {
edt {
val calls = mutableListOf<String>()
@@ -218,8 +228,7 @@ class SettingsListViewTest : BasePlatformTestCase() {
view.list.doLayout()
UIUtil.dispatchAllInvocationEvents()
val bounds = view.list.getCellBounds(0, 0)
val area = settingsListCellBounds(view.list, bounds, row, selected = true).getValue("edit")
val area = settingsListCellBounds(view.list, 0, selected = true).getValue("edit")
click(view, center(area))
@@ -1,6 +1,7 @@
package ai.kilocode.client.settings.providers
import ai.kilocode.client.app.KiloProviderService
import ai.kilocode.client.settings.base.SettingsListConfig
import ai.kilocode.client.settings.base.SettingsListItem
import ai.kilocode.client.settings.base.SettingsListRenderer
import ai.kilocode.client.settings.base.SettingsListActionCell
@@ -366,36 +367,33 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
fun `test renderer hit test maps actions`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val bounds = Rectangle(0, 0, 320, 48)
val areas = actionBounds(list, bounds, row, selected = true)
val list = hitList(row)
val areas = actionBounds(list, selected = true)
assertEquals(ProviderListAction.CONNECT, actionAt(list, bounds, center(areas.getValue(ProviderListAction.CONNECT)), row, selected = true))
assertEquals(ProviderListAction.OAUTH, actionAt(list, bounds, center(areas.getValue(ProviderListAction.OAUTH)), row, selected = true))
assertNull(actionAt(list, bounds, Point(4, 4), row, selected = true))
assertTrue(actionBounds(list, bounds, row, selected = false).isEmpty())
assertEquals(ProviderListAction.CONNECT, actionAt(list, center(areas.getValue(ProviderListAction.CONNECT)), selected = true))
assertEquals(ProviderListAction.OAUTH, actionAt(list, center(areas.getValue(ProviderListAction.OAUTH)), selected = true))
assertNull(actionAt(list, Point(4, 4), selected = true))
assertTrue(actionBounds(list, selected = false).isEmpty())
}
}
fun `test renderer keeps connected disconnect action visible when unselected`() {
edt {
val row = ProviderListRow(provider("openai", "OpenAI"), "Connected providers", listOf(ProviderListAction.DISCONNECT), connected = true)
val list = JBList(listOf(row))
val bounds = Rectangle(0, 0, 320, 48)
val area = actionBounds(list, bounds, row, selected = false).getValue(ProviderListAction.DISCONNECT)
val list = hitList(row)
val area = actionBounds(list, selected = false).getValue(ProviderListAction.DISCONNECT)
assertEquals(ProviderListAction.DISCONNECT, actionAt(list, bounds, center(area), row, selected = false))
assertEquals(ProviderListAction.DISCONNECT, actionAt(list, center(area), selected = false))
}
}
fun `test renderer ignores disabled env disconnect action`() {
edt {
val row = ProviderListRow(provider("env", "Env", source = "env"), "All providers", listOf(ProviderListAction.DISCONNECT))
val list = JBList(listOf(row))
val bounds = Rectangle(0, 0, 320, 48)
val area = actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.DISCONNECT)
val list = hitList(row)
val area = actionBounds(list, selected = true).getValue(ProviderListAction.DISCONNECT)
assertNull(actionAt(list, bounds, center(area), row, selected = true))
assertNull(actionAt(list, center(area), selected = true))
}
}
@@ -444,15 +442,14 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
fun `test disabled provider rows hide action labels and hit targets`() {
edt {
val row = ProviderListRow(provider("cloudflare", "Cloudflare"), "All providers", listOf(ProviderListAction.OAUTH, ProviderListAction.CONNECT), disabled = true)
val list = JBList(listOf(row))
val bounds = Rectangle(0, 0, 320, 48)
val list = hitList(row)
val renderer = renderer(row)
render(renderer, list, row, selected = true)
assertTrue(visibleActions(row, selected = true).isEmpty())
assertTrue(actionBounds(list, bounds, row, selected = true).isEmpty())
assertNull(actionAt(list, bounds, Point(300, 24), row, selected = true))
assertTrue(actionBounds(list, selected = true).isEmpty())
assertNull(actionAt(list, Point(300, 24), selected = true))
assertTrue(actionTexts(renderer).isEmpty())
}
}
@@ -529,15 +526,22 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
}
}
fun `test action bounds are vertically centered`() {
fun `test action hit target spans the full rendered button`() {
edt {
val row = ProviderListRow(provider("openai", "OpenAI"), "Popular providers", listOf(ProviderListAction.CONNECT))
val list = JBList(listOf(row))
val bounds = Rectangle(0, 10, 320, 80)
val area = actionBounds(list, bounds, row, selected = true).getValue(ProviderListAction.CONNECT)
val list = hitList(row)
val bounds = list.getCellBounds(0, 0)
val area = actionBounds(list, selected = true).getValue(ProviderListAction.CONNECT)
assertTrue(kotlin.math.abs((bounds.y + bounds.height / 2) - (area.y + area.height / 2)) <= 1)
assertTrue(bounds.contains(area))
// The button is right-aligned within the row.
assertTrue(area.x >= bounds.x + bounds.width / 2)
// Every horizontal slice of the drawn button resolves to the action, including the left
// edge that regressed when hit-testing ignored the New UI selection insets.
val y = area.y + area.height / 2
assertEquals(ProviderListAction.CONNECT, actionAt(list, Point(area.x + 1, y), selected = true))
assertEquals(ProviderListAction.CONNECT, actionAt(list, Point(area.x + area.width - 1, y), selected = true))
assertNull(actionAt(list, Point(area.x - 2, y), selected = true))
}
}
@@ -900,13 +904,24 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() {
.filter { it.iconWidth == JBUI.scale(20) && it.iconHeight == JBUI.scale(20) }
.map { Dimension(it.iconWidth, it.iconHeight) }
private fun actionAt(list: JBList<ProviderListRow>, bounds: Rectangle, point: Point, row: ProviderListRow, selected: Boolean): ProviderListAction? {
val id = settingsListCellAt(list, bounds, point, row, selected) ?: return null
/** Builds a list wired with the real [SettingsListRenderer] and laid out, so hit-testing matches what is drawn. */
private fun hitList(row: ProviderListRow): JBList<ProviderListRow> {
val model = CollectionListModel<ProviderListRow>(listOf(row))
val list = JBList(model)
list.cellRenderer = SettingsListRenderer(model as CollectionListModel<SettingsListItem>, SettingsListConfig.Preferred)
list.size = Dimension(320, 200)
list.doLayout()
UIUtil.dispatchAllInvocationEvents()
return list
}
private fun actionAt(list: JBList<ProviderListRow>, point: Point, selected: Boolean): ProviderListAction? {
val id = settingsListCellAt(list, 0, point, selected) ?: return null
return ProviderListAction.entries.firstOrNull { it.name == id }
}
private fun actionBounds(list: JBList<ProviderListRow>, bounds: Rectangle, row: ProviderListRow, selected: Boolean): Map<ProviderListAction, Rectangle> {
val cells = settingsListCellBounds(list, bounds, row, selected)
private fun actionBounds(list: JBList<ProviderListRow>, selected: Boolean): Map<ProviderListAction, Rectangle> {
val cells = settingsListCellBounds(list, 0, selected)
return cells.mapNotNull { (id, rect) -> ProviderListAction.entries.firstOrNull { it.name == id }?.let { it to rect } }.toMap()
}
@@ -46,6 +46,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
/** Message history returned by [messages]. */
val history = mutableListOf<MessageWithPartsDto>()
val histories = mutableMapOf<String, MutableList<MessageWithPartsDto>>()
var historyGate: CompletableDeferred<Unit>? = null
var historyCalls = 0
private set
@@ -218,7 +219,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
assertNotEdt("messages")
historyCalls++
historyGate?.await()
return history.toList()
return histories[id]?.toList() ?: history.toList()
}
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? {
@@ -0,0 +1,67 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.ui.md.hybrid.Kind
import ai.kilocode.client.ui.md.hybrid.MdLanguage
import ai.kilocode.client.ui.md.hybrid.Mode
import ai.kilocode.client.ui.md.hybrid.Stream
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.fileTypes.FileTypeRegistry
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.UnknownFileType
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class MdLanguageTest : BasePlatformTestCase() {
fun `test terminal tags resolve streams and modes`() {
assertKind("ansi", Stream.Stdout, Mode.Ansi)
assertKind("ansi-stdout", Stream.Stdout, Mode.Ansi)
assertKind("terminal", Stream.Stdout, Mode.Ansi)
assertKind("terminal-output", Stream.Stdout, Mode.Ansi)
assertKind("shell-command", Stream.Stdout, Mode.Command)
assertKind("shell-output", Stream.Stdout, Mode.Shell)
assertKind("ansi-stderr", Stream.Stderr, Mode.Ansi)
assertKind("terminal-error", Stream.Stderr, Mode.Ansi)
assertKind("shell-error", Stream.Stderr, Mode.Ansi)
}
fun `test source aliases resolve file types`() {
mapOf(
"rust" to "rs",
"ruby" to "rb",
"docker" to "dockerfile",
"c++" to "cpp",
"h++" to "hpp",
"csharp" to "cs",
"c#" to "cs",
"fsharp" to "fs",
"f#" to "fs",
"batch" to "bat",
"cmd" to "bat",
"make" to "makefile",
"terraform" to "tf",
"markdown" to "md",
"typescript" to "ts",
"yml" to "yaml",
).forEach { (lang, ext) ->
assertSame(type(ext), (MdLanguage.kind(lang) as Kind.Source).file)
}
}
fun `test shell script and metadata are normalized`() {
assertSame(type("sh"), (MdLanguage.kind("shell script") as Kind.Source).file)
assertSame(type("json"), (MdLanguage.kind(" json title=\"sample.json\" ") as Kind.Source).file)
assertKind(" ansi-stdout ignored metadata ", Stream.Stdout, Mode.Ansi)
}
private fun assertKind(lang: String, stream: Stream, mode: Mode) {
val kind = MdLanguage.kind(lang) as Kind.Terminal
assertEquals(stream, kind.stream)
assertEquals(mode, kind.mode)
}
private fun type(ext: String): FileType {
val type = FileTypeRegistry.getInstance().getFileTypeByExtension(ext)
if (type == UnknownFileType.INSTANCE) return PlainTextFileType.INSTANCE
return type
}
}
@@ -0,0 +1,72 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.ui.md.hybrid.Desc
import ai.kilocode.client.ui.md.hybrid.Kind
import ai.kilocode.client.ui.md.hybrid.MdProjector
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class MdProjectorTest : BasePlatformTestCase() {
private val projector = MdProjector()
fun `test prose coalesces and thematic breaks are filtered`() {
val out = projector.project("# Title\n\nfirst\n\n---\n\n- item")
assertEquals(1, out.blocks.size)
val html = out.blocks.single() as Desc.Html
assertTrue(html.body.contains("<h1>"))
assertTrue(html.body.contains("<ul>"))
assertFalse(html.body.contains("<hr"))
assertFalse(out.html.contains("<hr"))
}
fun `test fenced and indented code become code descs`() {
val out = projector.project("before\n\n```kotlin\nval x = 1\n```\n\n indented")
assertTrue(out.blocks[0] is Desc.Html)
val fenced = out.blocks[1] as Desc.Code
val indented = out.blocks[2] as Desc.Code
assertEquals("val x = 1\n", fenced.text)
assertEquals("indented\n", indented.text)
assertSame(PlainTextFileType.INSTANCE, (indented.kind as Kind.Source).file)
assertTrue(out.html.contains("<pre><code>val x = 1\n</code></pre>"))
}
fun `test table is extracted as its own block`() {
val out = projector.project("intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\noutro")
assertTrue(out.blocks[0] is Desc.Html)
assertTrue(out.blocks[1] is Desc.Table)
assertTrue(out.blocks[2] is Desc.Html)
assertTrue((out.blocks[1] as Desc.Table).body.contains("<table>"))
}
fun `test partial opener renders empty code block`() {
val out = projector.project("``")
assertEquals(listOf(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))), out.blocks)
assertEquals("<pre><code></code></pre>\n", out.html)
assertNull(out.open)
}
fun `test language prefix split stays out of code text`() {
val out = projector.project("```python\nprint(1)\n")
val code = out.blocks.single() as Desc.Code
assertEquals("print(1)\n", code.text)
assertFalse(out.html.contains("python"))
assertEquals('`', out.open!!.char)
}
fun `test partial closer is trimmed and complete closer closes`() {
val partial = projector.project("```python\nprint(1)\n``")
val complete = projector.project("```python\nprint(1)\n```\n\nafter")
assertEquals("print(1)\n", (partial.blocks.single() as Desc.Code).text)
assertNull(partial.open)
assertEquals(2, complete.blocks.size)
assertEquals("print(1)\n", (complete.blocks[0] as Desc.Code).text)
assertTrue((complete.blocks[1] as Desc.Html).body.contains("after"))
}
}
@@ -0,0 +1,50 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.ui.md.hybrid.MdShellHighlight
import ai.kilocode.client.ui.md.hybrid.ShellDisplay
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class MdShellHighlightTest : BasePlatformTestCase() {
fun `test project groups git stat commits and highlights semantic ranges`() {
val display = MdShellHighlight.project(
"""
475ab514 (HEAD -> main, origin/main) First change
src/App.kt | 2 ++
1 file changed, 1 insertion(+), 1 deletion(-)
e8b9785 Second change
src/Other.kt | 7 +++----
1 file changed, 3 insertions(+), 1 deletion(-)
<shell_metadata>
</shell_metadata>
...output truncated...
""".trimIndent(),
)
val spans = spans(display)
assertTrue(display.text.contains("1 deletion(-)\n\ne8b9785"))
assertTrue(spans.contains("475ab514" to DefaultLanguageHighlighterColors.NUMBER))
assertTrue(spans.contains("(HEAD -> main, origin/main)" to DefaultLanguageHighlighterColors.KEYWORD))
assertTrue(spans.contains("1 insertion(+)" to DefaultLanguageHighlighterColors.STRING))
assertTrue(spans.contains("1 deletion(-)" to DefaultLanguageHighlighterColors.LINE_COMMENT))
assertTrue(spans.contains("++" to DefaultLanguageHighlighterColors.STRING))
assertTrue(spans.contains("----" to DefaultLanguageHighlighterColors.LINE_COMMENT))
assertTrue(spans.contains("<shell_metadata>" to DefaultLanguageHighlighterColors.DOC_COMMENT))
assertTrue(spans.contains("...output truncated..." to DefaultLanguageHighlighterColors.KEYWORD))
}
fun `test command highlights commands flags strings and env vars`() {
val display = MdShellHighlight.command("FOO=bar; git commit -m 'hello world' --amend")
val spans = spans(display)
assertTrue(spans.contains("git" to DefaultLanguageHighlighterColors.FUNCTION_CALL))
assertTrue(spans.contains("-m" to DefaultLanguageHighlighterColors.KEYWORD))
assertTrue(spans.contains("--amend" to DefaultLanguageHighlighterColors.KEYWORD))
assertTrue(spans.contains("'hello world'" to DefaultLanguageHighlighterColors.STRING))
assertTrue(spans.contains("FOO" to DefaultLanguageHighlighterColors.STATIC_FIELD))
}
private fun spans(display: ShellDisplay) = display.ranges.map {
display.text.substring(it.start, it.end) to it.key
}
}
@@ -1,6 +1,8 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.ui.md.hybrid.MdTerminal
import ai.kilocode.client.ui.md.hybrid.Stream
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class MdTerminalTest : BasePlatformTestCase() {
@@ -23,4 +25,25 @@ class MdTerminalTest : BasePlatformTestCase() {
assertEquals("green", MdTerminal.strip("\u001B[32mgreen\u001B[0m"))
assertTrue(MdTerminal.hasAnsi("\u001B[32mgreen\u001B[0m"))
}
fun `test decode produces ranges for sgr coloring`() {
val term = MdTerminal.decode("\u001B[32mgreen\u001B[0m\n", Stream.Stdout)
assertEquals("green", term.text)
assertTrue(term.ranges.any { term.text.substring(it.start, it.end) == "green" })
}
fun `test decode uses stdout and stderr keys`() {
val out = MdTerminal.decode("ok", Stream.Stdout)
val err = MdTerminal.decode("boom", Stream.Stderr)
assertEquals(ProcessOutputTypes.STDOUT, out.ranges.single().key)
assertEquals(ProcessOutputTypes.STDERR, err.ranges.single().key)
}
fun `test decode trims trailing newlines`() {
val term = MdTerminal.decode("one\n\n", Stream.Stdout)
assertEquals("one", term.text)
}
}
@@ -0,0 +1,46 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.HighlighterColors
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorColorsScheme
import com.intellij.openapi.editor.markup.TextAttributes
import java.awt.Color
import java.awt.Font
internal fun customStyle(): SessionEditorStyle {
val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme
scheme.setAttributes(
HighlighterColors.TEXT,
TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_COMMENT,
TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.LINE_COMMENT,
TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_INLINE,
TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.STRING,
TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_BLOCK,
TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN),
)
scheme.setAttributes(
CodeInsightColors.HYPERLINK_ATTRIBUTES,
TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN),
)
scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44))
return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21)
}
@@ -164,6 +164,42 @@ class MdViewHybridStressTest : BasePlatformTestCase() {
assertTrue(view.markdown().contains("line 49"))
}
fun `test repeated table set reuses single pane and stays bounded`() {
repeat(150) { i -> view.set("| a | b |\n|---|---|\n| $i | ${i + 1} |") }
val pane = scrolls().single()
val inner = pane.viewport.view as JBHtmlPane
repeat(50) { i -> view.set("| a | b |\n|---|---|\n| y$i | z$i |") }
assertSame(pane, scrolls().single())
assertSame(inner, scrolls().single().viewport.view)
assertEquals(1, scrolls().size)
assertEquals(0, htmls().size)
assertEquals(1, panel().componentCount)
assertTrue(inner.text.contains("y49"))
}
fun `test churn across prose code and table stays bounded and leak free`() {
val base = EditorFactory.getInstance().allEditors.size
repeat(60) { i ->
view.set("prose $i")
view.set("```kotlin\nval x = $i\n```")
editors().single().getEditor(true)
view.set("| a | b |\n|---|---|\n| $i | ${i + 1} |")
assertEquals(1, scrolls().size)
assertTrue(editors().isEmpty())
}
view.clear()
drainEdt()
assertTrue(scrolls().isEmpty())
assertTrue(htmls().isEmpty())
assertEquals(0, panel().componentCount)
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
private fun panel(): JPanel = view.component as JPanel
private fun scrolls(): List<JBScrollPane> = panel().components.filterIsInstance<JBScrollPane>()
@@ -9,16 +9,10 @@ import com.intellij.execution.ui.ConsoleViewContentType
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.HighlighterColors
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorColorsScheme
import com.intellij.openapi.fileTypes.FileType
import com.intellij.openapi.fileTypes.FileTypeRegistry
import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.UnknownFileType
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
@@ -29,16 +23,16 @@ import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Font
import java.awt.Point
import java.awt.datatransfer.DataFlavor
import java.awt.event.MouseEvent
import java.net.URI
import javax.swing.Box
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.event.HyperlinkEvent
import javax.swing.text.html.HTML
import javax.swing.text.html.HTMLDocument
import java.awt.datatransfer.DataFlavor
@Suppress("UnstableApiUsage")
class MdViewHybridTest : BasePlatformTestCase() {
@@ -771,6 +765,91 @@ class MdViewHybridTest : BasePlatformTestCase() {
assertEquals(pane.background, pane.viewport.background)
}
fun `test table renders in horizontal scroll pane without an editor`() {
view.set("| a | b |\n|---|---|\n| 1 | 2 |")
val pane = scrolls().single()
val inner = pane.viewport.view as JBHtmlPane
assertTrue(editors().isEmpty())
assertTrue(htmls().isEmpty())
assertTrue(inner.text.contains("<table>"))
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, pane.horizontalScrollBarPolicy)
assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, pane.verticalScrollBarPolicy)
assertTrue(pane.horizontalScrollBar.preferredSize.height > 0)
assertEquals(0, pane.verticalScrollBar.preferredSize.width)
}
fun `test wide table width is bounded and boxed`() {
val header = (1..10).joinToString("|", prefix = "|", postfix = "|") { " column$it " }
val sep = (1..10).joinToString("|", prefix = "|", postfix = "|") { "---" }
val row = (1..10).joinToString("|", prefix = "|", postfix = "|") { " ${"x".repeat(20)} " }
view.set("$header\n$sep\n$row")
val pane = scrolls().single()
val inner = pane.viewport.view as JBHtmlPane
assertEquals(0, pane.preferredSize.width)
assertTrue(inner.preferredSize.width > pane.preferredSize.width)
assertTrue(pane.maximumSize.width > 1000)
}
fun `test table pane reserves full table height and does not clip vertically`() {
val rows = (1..8).joinToString("\n") { "| r${it}c1 | r${it}c2 |" }
view.set("| a | b |\n|---|---|\n$rows")
val pane = scrolls().single()
val inner = pane.viewport.view as JBHtmlPane
layout(width = 420)
val bar = pane.horizontalScrollBar.preferredSize.height
assertTrue("pane preferred height should cover the rendered table", pane.preferredSize.height >= inner.preferredSize.height + bar)
assertTrue("table should not be clipped vertically", pane.height >= inner.preferredSize.height)
}
fun `test table separates surrounding prose runs`() {
view.set("intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\noutro")
val html = htmls()
assertEquals(2, html.size)
assertEquals(1, scrolls().size)
assertTrue(editors().isEmpty())
assertEquals(2, struts().size)
assertTrue(html[0].text.contains("intro"))
assertTrue(html[1].text.contains("outro"))
assertTrue((scrolls().single().viewport.view as JBHtmlPane).text.contains("<table>"))
}
fun `test rerendering table reuses retained scroll pane and html child`() {
view.set("| a | b |\n|---|---|\n| 1 | 2 |")
val pane = scrolls().single()
val inner = pane.viewport.view as JBHtmlPane
view.set("| a | b |\n|---|---|\n| 3 | 4 |")
assertSame(pane, scrolls().single())
assertSame(inner, scrolls().single().viewport.view)
assertEquals(1, scrolls().size)
assertTrue(inner.text.contains("4"))
}
fun `test replacing table with prose disposes the scroll pane`() {
view.set("| a | b |\n|---|---|\n| 1 | 2 |")
assertEquals(1, scrolls().size)
view.set("plain prose")
drainEdt()
assertTrue(scrolls().isEmpty())
assertTrue(htmls().single().text.contains("plain prose"))
}
fun `test clear disposes table scroll pane`() {
view.set("| a | b |\n|---|---|\n| 1 | 2 |")
view.clear()
assertEquals("", view.markdown())
assertTrue(scrolls().isEmpty())
}
fun `test clear resets source and components`() {
view.set("```\ncode\n```")
view.clear()
@@ -906,6 +985,25 @@ class MdViewHybridTest : BasePlatformTestCase() {
assertEquals("https://example.com", received.single().href)
}
fun `test link listener receives activated prose link with component`() {
val received = mutableListOf<MdView.LinkEvent>()
view.addLinkListener { received.add(it) }
view.set("See [docs](https://example.com)")
val pane = htmls().single()
val event = HyperlinkEvent(
pane,
HyperlinkEvent.EventType.ACTIVATED,
URI("https://example.com").toURL(),
"https://example.com",
)
pane.hyperlinkListeners.forEach { it.hyperlinkUpdate(event) }
val link = received.single()
assertEquals("https://example.com", link.href)
assertSame(pane, link.component)
}
fun `test markdown root and code child expose selection copy provider`() {
Disposer.dispose(view)
disposed = true
@@ -933,6 +1031,26 @@ class MdViewHybridTest : BasePlatformTestCase() {
}
}
fun `test setSelection resyncs blocks and code child exposes selection copy provider`() {
view.set("```text\nalpha code\n```")
val old = editors().single().getEditor(true)!!
val selection = SessionSelection()
try {
view.setSelection(selection)
drainEdt()
val field = editors().single()
val child = CopyProviderSink()
(field as UiDataProvider).uiDataSnapshot(child)
assertTrue(old.isDisposed)
assertNotNull(child.copy)
assertEquals("alpha code", field.text)
} finally {
selection.dispose()
}
}
private fun scrolls(): List<JBScrollPane> = (view.component as JPanel).components.filterIsInstance<JBScrollPane>()
private fun htmls(): List<JBHtmlPane> = (view.component as JPanel).components.filterIsInstance<JBHtmlPane>()
@@ -960,37 +1078,4 @@ class MdViewHybridTest : BasePlatformTestCase() {
UIUtil.dispatchAllInvocationEvents()
}
private fun customStyle(): SessionEditorStyle {
val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme
scheme.setAttributes(
HighlighterColors.TEXT,
TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_COMMENT,
TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.LINE_COMMENT,
TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_INLINE,
TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.STRING,
TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_BLOCK,
TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN),
)
scheme.setAttributes(
CodeInsightColors.HYPERLINK_ATTRIBUTES,
TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN),
)
scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44))
return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21)
}
}
@@ -2,20 +2,13 @@ package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.HighlighterColors
import com.intellij.openapi.editor.colors.CodeInsightColors
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorColorsScheme
import com.intellij.openapi.editor.markup.TextAttributes
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Color
import java.awt.Font
/**
* Tests for the fallback HTML [MdView].
* Tests for the hybrid markdown renderer's HTML and CSS output.
*
* Uses [BasePlatformTestCase] to get a real IntelliJ Application so that
* JBHtmlPane initialisation works correctly.
@@ -568,38 +561,4 @@ class MdViewTest : BasePlatformTestCase() {
assertTrue(view.html().contains("<strong>"))
}
private fun customStyle(): SessionEditorStyle {
val scheme = EditorColorsManager.getInstance().globalScheme.clone() as EditorColorsScheme
scheme.setAttributes(
HighlighterColors.TEXT,
TextAttributes(Color(0x10, 0x20, 0x30), Color(0x01, 0x02, 0x03), null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_COMMENT,
TextAttributes(Color(0x33, 0x44, 0x55), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.LINE_COMMENT,
TextAttributes(Color(0x44, 0x55, 0x66), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_INLINE,
TextAttributes(Color(0xAA, 0xBB, 0xCC), Color(0x11, 0x22, 0x33), null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.STRING,
TextAttributes(Color(0xCC, 0x88, 0x66), null, null, null, Font.PLAIN),
)
scheme.setAttributes(
DefaultLanguageHighlighterColors.DOC_CODE_BLOCK,
TextAttributes(Color(0xDD, 0xEE, 0xFF), Color(0x44, 0x55, 0x66), null, null, Font.PLAIN),
)
scheme.setAttributes(
CodeInsightColors.HYPERLINK_ATTRIBUTES,
TextAttributes(Color(0x77, 0x88, 0x99), null, null, null, Font.PLAIN),
)
scheme.setColor(EditorColors.PREVIEW_BORDER_COLOR, Color(0x22, 0x33, 0x44))
return SessionEditorStyle.create(scheme = scheme, family = "Courier New", size = 21)
}
}