diff --git a/.changeset/fix-jetbrains-prompt-submit.md b/.changeset/fix-jetbrains-prompt-submit.md new file mode 100644 index 00000000000..d028d74abcb --- /dev/null +++ b/.changeset/fix-jetbrains-prompt-submit.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix prompt submission in JetBrains IDEs when sending messages with file or git-change mentions. diff --git a/.changeset/fix-jetbrains-provider-action-clicks.md b/.changeset/fix-jetbrains-provider-action-clicks.md new file mode 100644 index 00000000000..c026cc8bb12 --- /dev/null +++ b/.changeset/fix-jetbrains-provider-action-clicks.md @@ -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. diff --git a/.changeset/focused-jetbrains-prompt.md b/.changeset/focused-jetbrains-prompt.md new file mode 100644 index 00000000000..17d01ce6f67 --- /dev/null +++ b/.changeset/focused-jetbrains-prompt.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a focus outline around the JetBrains prompt input. diff --git a/.changeset/jetbrains-inline-subagents.md b/.changeset/jetbrains-inline-subagents.md new file mode 100644 index 00000000000..4928ad409bd --- /dev/null +++ b/.changeset/jetbrains-inline-subagents.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show subagent tool activity inline in JetBrains session transcripts. diff --git a/.changeset/jetbrains-picker-popups.md b/.changeset/jetbrains-picker-popups.md new file mode 100644 index 00000000000..fb7f2eebea3 --- /dev/null +++ b/.changeset/jetbrains-picker-popups.md @@ -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. diff --git a/.changeset/jetbrains-prompt-floating-toolbar.md b/.changeset/jetbrains-prompt-floating-toolbar.md new file mode 100644 index 00000000000..2480cdf891b --- /dev/null +++ b/.changeset/jetbrains-prompt-floating-toolbar.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Hide the JetBrains editor floating toolbar from the Kilo prompt input. diff --git a/.changeset/jetbrains-shell-tooltip-padding.md b/.changeset/jetbrains-shell-tooltip-padding.md new file mode 100644 index 00000000000..6ab1a926d97 --- /dev/null +++ b/.changeset/jetbrains-shell-tooltip-padding.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Balance JetBrains shell command tooltip padding when a horizontal scrollbar is present. diff --git a/.changeset/jetbrains-todo-padding.md b/.changeset/jetbrains-todo-padding.md new file mode 100644 index 00000000000..1a3672774df --- /dev/null +++ b/.changeset/jetbrains-todo-padding.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Increase JetBrains todo checklist inner padding. diff --git a/.changeset/jetbrains-transcript-prompt-font.md b/.changeset/jetbrains-transcript-prompt-font.md new file mode 100644 index 00000000000..21212ab9f4b --- /dev/null +++ b/.changeset/jetbrains-transcript-prompt-font.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use the standard transcript font for JetBrains prompt text and custom question responses. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 495e450ab6a..2e7945cd4ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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 = runBlockingCancellable { + private suspend fun mentionParts(text: String): List { val names = MentionAction.ALL.mapTo(mutableSetOf()) { it.name } - promptMentionParts( + return promptMentionParts( text = text, directory = workspace.directory, reserved = names, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index e594cc96575..f7b3e9a5400 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -147,6 +147,7 @@ class SessionController( private var creating: CompletableDeferred? = null private val childJobs: MutableMap = mutableMapOf() private val childIds: MutableSet = mutableSetOf() + private val childParts: MutableMap = 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() - 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): Map = 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt index e2ab924b547..7eeaa951500 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Message.kt @@ -82,6 +82,8 @@ class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) { var title: String? = null var input: Map = emptyMap() var metadata: Map = emptyMap() + var childSessionId: String? = null + var childTools: List = emptyList() var output: String? = null var error: String? = null var time: PartTimeDto? = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 5aedad3b1f7..6e06ad4bccd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -47,6 +47,9 @@ class SessionModel { private val entries = LinkedHashMap() private val turnEntries = LinkedHashMap() private val hiddenText = mutableSetOf>() + private val childRefs = HashMap() + private val childTools = HashMap>() + private val childRemoved = HashMap>() 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) { 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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt index b3db2c7d410..cd1ac3e39b9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index f42a02439ab..7847bade604 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -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 + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 19edae0d41f..b9d467c36bc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -379,11 +379,6 @@ class ModelPicker : PickerButton() { e.consume() return } - if (expanded && e.clickCount < 2) { - list.selectedIndex = row - syncDetails() - return - } activate(value) } }) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 65016594979..19db08ab224 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -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) -> Unit, private val onAbort: () -> Unit, private val onEnhance: (String, (Result) -> Unit) -> Unit, - private val onMentions: (String) -> List = { emptyList() }, + private val onMentions: suspend (String) -> List = { 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: CancellationException) { + withContext(NonCancellable + Dispatchers.Main) { + submitting = false + } + throw e } catch (e: Exception) { - ApplicationManager.getApplication().invokeLater { + withContext(Dispatchers.Main) { submitting = false - if (project.isDisposed) return@invokeLater + 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)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 19c76b825d3..16f40d3449e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt index 4b0b665e165..5071f431b6d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index ece0854420a..b736a08bca3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index a16579f6fc8..b5dbac40be1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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() // 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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt index 588c8d266de..7d3f7e267e9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PromptView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 10886c589c0..68c06d142a3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index d4e04515aae..eb6d0b329a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt index 62da49a8d96..7005a6eda05 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoListPanel.kt @@ -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 = 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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 131bbc560a2..ef7483eeef3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index be75209af21..fd1db6ec170 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -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().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. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt new file mode 100644 index 00000000000..51f080b24a7 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/TaskToolView.kt @@ -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() + 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)" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt index 01e106ec8ad..81cac63adc8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListModel.kt @@ -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 settingsListCellAt( - list: JList<*>, - bounds: Rectangle, - point: Point, - item: SettingsListItem, - selected: Boolean, -): String? { - val cells = settingsListCellBounds(list, bounds, item, selected) - return settingsListVisibleCells(item, selected) - .firstOrNull { cell -> cell.enabled && cells[cell.id]?.contains(point) == true } - ?.id -} +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<*>, - bounds: Rectangle, - item: SettingsListItem, + index: Int, selected: Boolean, ): Map { - val height = settingsListCellHeight(list) - var edge = bounds.x + bounds.width - UiStyle.Gap.pad() + val model = list.model + if (index < 0 || index >= model.size) return emptyMap() + @Suppress("UNCHECKED_CAST") + val renderer = list.cellRenderer as? ListCellRenderer ?: 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() - 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) + 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 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)) +internal fun settingsListCellAt( + list: JList<*>, + index: Int, + point: Point, + selected: Boolean, +): String? { + 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 } -private fun settingsListCellHeight(list: JList<*>): Int { - val metrics = list.getFontMetrics(list.font) - return metrics.height + UiStyle.Gap.sm() * 2 +private fun settingsListLayout(component: Component) { + if (component !is Container) return + component.doLayout() + for (child in component.components) settingsListLayout(child) } -internal fun settingsListCellGap() = JBUI.scale(CELL_GAP) +private fun settingsListActionCells(component: Component): List { + val out = mutableListOf() + fun visit(c: Component) { + if (c is SettingsListActionCell && c.isVisible) out += c + if (c is Container) c.components.forEach(::visit) + } + visit(component) + return out +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt index 04da2eb0397..314e2c64b7f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListRenderer.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt index b42042473f1..661ac90103f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/base/SettingsListView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt new file mode 100644 index 00000000000..1d3e077951b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdProjector.kt @@ -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() + 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 { + val visitor = Visitor() + doc.accept(visitor) + return visitor.blocks + } + + private fun lines(text: String): List { + if (text.isEmpty()) return emptyList() + val lines = mutableListOf() + 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 = "
${escape(text)}
\n" + + private fun escape(text: String): String = text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + + private inner class Visitor : AbstractVisitor() { + val blocks = mutableListOf() + private val run = StringBuilder() + + override fun visit(document: Document) { + visitChildren(document) + flush() + } + + override fun visit(code: FencedCodeBlock) { + flush() + blocks.add(Desc.Code(code.literal, 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, 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt index 2422c9b9810..c8c257cf5d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdTerminal.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index 973d8b9e5ed..e3d04e8fc80 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -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() private var openFence: Fence? = null private var stale = false - - 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 projector = MdProjector() + + 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(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 { - val visitor = Visitor() - doc.accept(visitor) - return visitor.blocks - } - - private fun project(text: String): Projection { - val blocks = mutableListOf() - 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 { - if (text.isEmpty()) return emptyList() - val lines = mutableListOf() - 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 = "
${escape(text)}
\n" - - private fun escape(text: String): String = text - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - - 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, 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() - 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 - } - } - } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 811f29dc0a4..c64398d56ec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 7d94f601a8c..8449703a982 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -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() + 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(), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 075c26fc9a2..fe17e9a103d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -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( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index bac03d4c9ca..a4838926a4f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -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() @@ -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() + val gate = CompletableDeferred() + 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 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index 46fc602efa2..37ff1cfae56 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index c2287d31ee5..13b9740f41a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -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 { + val scroll = components(view).filterIsInstance().single() + val stack = components(scroll.viewport.view).filterIsInstance().single() + return stack.components.map { row -> + components(row).filterIsInstance() + .mapNotNull { label -> label.text.takeIf { it.isNotBlank() } } + .joinToString(" ") + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 1705776fac7..dee8f91c9ff 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -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`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt index e5d8268676a..b7817c62ab6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ShellToolViewTest.kt @@ -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 { + val found = mutableListOf() + fun visit(component: JComponent) { + if (component is JBScrollPane) found.add(component) + component.components.filterIsInstance().forEach(::visit) + } + visit(root) + return found + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt new file mode 100644 index 00000000000..e894b0ec910 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewStressTest.kt @@ -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() + + 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 = 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 { + val scroll = descendants(view).filterIsInstance().single() + val stack = descendants(scroll.viewport.view).filterIsInstance().single() + return stack.components.toList() + } + + private fun descendants(root: Component): List { + if (root !is Container) return emptyList() + return root.components.flatMap { child -> listOf(child) + descendants(child) } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt new file mode 100644 index 00000000000..8b70bcf35a4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TaskToolViewTest.kt @@ -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() + + 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 = 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().singleOrNull() + + private fun body(view: TaskToolView) = scroll(view)!!.viewport.view as JComponent + + private fun rows(view: TaskToolView): List { + val stack = descendants(body(view)).filterIsInstance().singleOrNull() ?: return emptyList() + return stack.components.toList() + } + + private fun rowText(view: TaskToolView) = rows(view).map { row -> + descendants(row).filterIsInstance().mapNotNull { label -> label.text.takeIf { it.isNotBlank() } }.joinToString(" ") + } + + private fun titleColor(view: TaskToolView, index: Int) = descendants(rows(view)[index]) + .filterIsInstance() + .first { it.text.isNotBlank() } + .foreground + + private fun descendants(root: Component): List { + 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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 3fcd18f6e98..b7849b5b7cf 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -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()) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt index c4b56fe2078..b4af0eba89f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/todo/TodoWriteViewTest.kt @@ -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().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( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt index 71912dbbfaa..55ee4432754 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/AgentsSettingsUiTest.kt @@ -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)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt index ddaf9ef93f9..aff659e9fbc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/agents/McpSettingsUiTest.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt index 6bc0e25ae52..ca7f5cd7c8e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/base/SettingsListViewTest.kt @@ -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() @@ -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)) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 5db81bbf411..6c6da0923c8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -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, 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 { + val model = CollectionListModel(listOf(row)) + val list = JBList(model) + list.cellRenderer = SettingsListRenderer(model as CollectionListModel, SettingsListConfig.Preferred) + list.size = Dimension(320, 200) + list.doLayout() + UIUtil.dispatchAllInvocationEvents() + return list + } + + private fun actionAt(list: JBList, 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, bounds: Rectangle, row: ProviderListRow, selected: Boolean): Map { - val cells = settingsListCellBounds(list, bounds, row, selected) + private fun actionBounds(list: JBList, selected: Boolean): Map { + val cells = settingsListCellBounds(list, 0, selected) return cells.mapNotNull { (id, rect) -> ProviderListAction.entries.firstOrNull { it.name == id }?.let { it to rect } }.toMap() } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index a7053469f27..389082cb1d1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -46,6 +46,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Message history returned by [messages]. */ val history = mutableListOf() + val histories = mutableMapOf>() var historyGate: CompletableDeferred? = 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? { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt new file mode 100644 index 00000000000..7921173f52c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdLanguageTest.kt @@ -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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt new file mode 100644 index 00000000000..2e34f69dc37 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdProjectorTest.kt @@ -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("

")) + assertTrue(html.body.contains("
    ")) + assertFalse(html.body.contains("val x = 1\n")) + } + + 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("")) + } + + fun `test partial opener renders empty code block`() { + val out = projector.project("``") + + assertEquals(listOf(Desc.Code("", Kind.Source(PlainTextFileType.INSTANCE))), out.blocks) + assertEquals("
    \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")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt new file mode 100644 index 00000000000..47da0f01e23 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdShellHighlightTest.kt @@ -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(-) + + + ...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("" 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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt index c7766d764b1..a5cc144c99e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTerminalTest.kt @@ -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) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt new file mode 100644 index 00000000000..68cfcb90c8a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdTestStyles.kt @@ -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) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt index e9d40248cf6..50d60a5d72a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt @@ -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 = panel().components.filterIsInstance() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index d029c0087a2..b80b7489614 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -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("
    ")) + 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("
    ")) + } + + 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() + 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 = (view.component as JPanel).components.filterIsInstance() private fun htmls(): List = (view.component as JPanel).components.filterIsInstance() @@ -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) - } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 16ba83868a7..598483cfe15 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -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("")) } - 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) - } - }