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