fix(jetbrains): support copying session text

This commit is contained in:
kirillk
2026-06-02 10:31:45 -04:00
parent bee54755ff
commit 2a3975ea29
22 changed files with 658 additions and 36 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Support coherent selection and copy behavior across JetBrains session transcript fragments.
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.ui.account.SessionAccountOverlay
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
@@ -38,6 +39,11 @@ import ai.kilocode.log.ChatLogSummary
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import com.intellij.ide.BrowserUtil
import com.intellij.ide.TextCopyProvider
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.PlatformDataKeys
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
@@ -79,7 +85,7 @@ class SessionUi(
private val manager: SessionManager? = null,
private val workspaces: KiloWorkspaceService = service(),
private val migration: MigrationUiController = service<KiloMigrationService>(),
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget {
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget, UiDataProvider {
companion object {
private val LOG = KiloLog.create(SessionUi::class.java)
@@ -143,12 +149,22 @@ class SessionUi(
private var empty: EmptySessionPanel? = null
private var modalFocus: (() -> JComponent)? = null
private var style = SessionEditorStyle.current()
private val selection = SessionSelection()
private val copy = object : TextCopyProvider() {
override fun getActionUpdateThread() = ActionUpdateThread.EDT
override fun getTextLinesToCopy(): Collection<String>? {
val text = selection.selectedText()?.takeIf { it.isNotEmpty() } ?: return null
return listOf(text)
}
}
private var editorTheme = style.editorScheme
private var colorTheme = UIManager.getLookAndFeel()
private var disposed = false
init {
buildUi()
Disposer.register(this, selection)
scroll.show(body(controller.model.state))
bindUi()
bindStyle()
@@ -178,6 +194,10 @@ class SessionUi(
internal fun currentStyle() = style
override fun uiDataSnapshot(sink: DataSink) {
sink[PlatformDataKeys.COPY_PROVIDER] = copy
}
@RequiresEdt
internal fun canDisposeInactive(): Boolean = controller.model.state is SessionState.Idle
@@ -259,12 +279,18 @@ class SessionUi(
reject = { id -> controller.rejectQuestion(id) },
follow = { scroll.following() },
scroll = { scroll.followBottom(it) },
selection = selection,
)
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
selection = selection,
)
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl)
login = LoginRequiredView(
openProfile = { controller.openProfile() },
dismiss = { controller.dismissLoginRequired() },
selection = selection,
)
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl, selection)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -524,6 +550,7 @@ class SessionUi(
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
selection.applyStyle(style)
editorTheme = style.editorScheme
colorTheme = UIManager.getLookAndFeel()
background = style.editorBackground
@@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.LoginRequiredView
@@ -49,6 +50,7 @@ class SessionMessageListPanel(
private val login: LoginRequiredView? = null,
private val openFile: (String) -> Unit,
private val openUrl: (String) -> Unit = {},
private val selection: SessionSelection? = null,
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -177,7 +179,7 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, openFile, style, openUrl)
val tv = TurnView(turn.id, openFile, style, openUrl, selection)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -233,7 +235,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, openFile, style, openUrl)
val tv = TurnView(turn.id, openFile, style, openUrl, selection)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -0,0 +1,255 @@
package ai.kilocode.client.session.ui.selection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import com.intellij.openapi.Disposable
import com.intellij.openapi.editor.colors.EditorColors
import com.intellij.openapi.editor.event.SelectionEvent
import com.intellij.openapi.editor.event.SelectionListener
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.util.concurrency.annotations.RequiresEdt
import java.awt.Color
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.event.CaretEvent
import javax.swing.event.CaretListener
import javax.swing.text.JTextComponent
class SessionSelection : Disposable {
private val items = linkedSetOf<Item>()
private var active: Item? = null
private var style: SessionEditorStyle? = null
private var clearing = false
private var disposed = false
@RequiresEdt
fun selectedText(): String? {
active?.selectedText()?.takeIf { it.isNotEmpty() }?.let { return it }
val item = items.toList().asReversed().firstOrNull { !it.selectedText().isNullOrEmpty() }
if (item == null) {
active = null
return null
}
active = item
clearExcept(item)
return item.selectedText()?.takeIf { it.isNotEmpty() }
}
@RequiresEdt
fun register(component: JTextComponent, parent: Disposable? = null): Disposable {
val item = TextItem(component)
add(item, parent)
return item
}
@RequiresEdt
fun register(field: EditorTextField, parent: Disposable? = null): Disposable {
val item = FieldItem(field)
add(item, parent)
return item
}
@RequiresEdt
fun register(editor: EditorEx, parent: Disposable? = null): Disposable {
val item = EditorItem(editor)
add(item, parent)
return item
}
@RequiresEdt
private fun clearExcept(item: Item) {
if (clearing) return
clearing = true
try {
for (entry in items) {
if (entry !== item) entry.clearSelection()
}
} finally {
clearing = false
}
}
@RequiresEdt
fun clear() {
if (clearing) return
clearing = true
try {
for (entry in items) entry.clearSelection()
active = null
} finally {
clearing = false
}
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (item in items) item.applyStyle(style)
}
@RequiresEdt
override fun dispose() {
disposed = true
clear()
val copy = items.toList()
for (item in copy) item.dispose()
items.clear()
active = null
}
@RequiresEdt
private fun add(item: Item, parent: Disposable?) {
if (disposed) return
items.add(item)
style?.let(item::applyStyle)
parent?.let { Disposer.register(it, item) }
}
@RequiresEdt
private fun changed(item: Item) {
if (clearing || item.disposed) return
if (!items.contains(item)) return
if (!item.selectedText().isNullOrEmpty()) {
active = item
clearExcept(item)
return
}
if (active === item) active = null
}
@RequiresEdt
private fun started(item: Item) {
if (clearing || item.disposed) return
if (!items.contains(item)) return
active = item
clearExcept(item)
}
private interface Item : Disposable {
val disposed: Boolean
fun selectedText(): String?
fun clearSelection()
fun applyStyle(style: SessionEditorStyle)
}
private inner class TextItem(private val component: JTextComponent) : Item, CaretListener {
private val mouse = object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) = started(this@TextItem)
}
override var disposed = false
private set
init {
component.caret.isSelectionVisible = true
component.caret.isVisible = false
component.isFocusable = true
component.isRequestFocusEnabled = true
component.addCaretListener(this)
component.addMouseListener(mouse)
}
override fun caretUpdate(e: CaretEvent) = changed(this)
override fun selectedText(): String? = component.selectedText
override fun clearSelection() {
val pos = component.selectionStart.coerceIn(0, component.document.length)
component.select(pos, pos)
}
override fun applyStyle(style: SessionEditorStyle) {
selectionColors(style, component.selectionColor, component.selectedTextColor).let {
component.selectionColor = it.first
component.selectedTextColor = it.second
}
}
override fun dispose() {
if (disposed) return
disposed = true
component.removeCaretListener(this)
component.removeMouseListener(mouse)
if (active === this) active = null
items.remove(this)
}
}
private inner class FieldItem(private val field: EditorTextField) : Item, SelectionListener {
private var editor: EditorEx? = null
private var reg: Disposable? = null
override var disposed = false
private set
init {
field.getEditor(false)?.let(::bind)
field.addSettingsProvider { ed -> bind(ed) }
}
override fun selectedText(): String? = editor?.selectionModel?.selectedText
override fun clearSelection() {
editor?.selectionModel?.removeSelection()
}
override fun applyStyle(style: SessionEditorStyle) {
editor?.let(style::applyToEditor)
}
override fun selectionChanged(e: SelectionEvent) = changed(this)
override fun dispose() {
if (disposed) return
disposed = true
reg?.let(Disposer::dispose)
reg = null
editor = null
if (active === this) active = null
items.remove(this)
}
private fun bind(editor: EditorEx) {
if (disposed || this.editor != null) return
this.editor = editor
val disposable = Disposer.newDisposable("Session selection editor")
reg = disposable
editor.selectionModel.addSelectionListener(this, disposable)
style?.let(::applyStyle)
}
}
private inner class EditorItem(private val editor: EditorEx) : Item, SelectionListener {
override var disposed = false
private set
init {
editor.selectionModel.addSelectionListener(this, this)
}
override fun selectionChanged(e: SelectionEvent) = changed(this)
override fun selectedText(): String? = editor.selectionModel.selectedText
override fun clearSelection() {
editor.selectionModel.removeSelection()
}
override fun applyStyle(style: SessionEditorStyle) {
style.applyToEditor(editor)
}
override fun dispose() {
if (disposed) return
disposed = true
if (active === this) active = null
items.remove(this)
}
}
private fun selectionColors(style: SessionEditorStyle, bg: Color?, fg: Color?): Pair<Color, Color> {
val scheme = style.editorScheme
return (scheme.getColor(EditorColors.SELECTION_BACKGROUND_COLOR) ?: bg ?: style.editorBackground) to
(scheme.getColor(EditorColors.SELECTION_FOREGROUND_COLOR) ?: fg ?: style.editorForeground)
}
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -21,11 +22,12 @@ import javax.swing.JButton
class LoginRequiredView(
private val openProfile: () -> Unit,
private val dismiss: () -> Unit,
selection: SessionSelection? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private val card = BaseQuestionView()
private val card = BaseQuestionView(selection)
private val ID_DISMISS = "dismiss"
private val ID_OPEN = "open"
@@ -8,6 +8,7 @@ import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
@@ -34,6 +35,7 @@ class MessageView(
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
private val selection: SessionSelection? = null,
) : ai.kilocode.client.session.ui.SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
), Disposable, SessionEditorStyleTarget, SessionView {
@@ -174,9 +176,9 @@ class MessageView(
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile, openUrl)
ViewFactory.createUser(content, openFile, openUrl, selection)
} else {
ViewFactory.create(content, openFile, openUrl)
ViewFactory.create(content, openFile, openUrl, selection)
}
/** Append a streaming delta to the renderer for [contentId]. */
@@ -5,12 +5,15 @@ 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.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.md.MdViewFactory
import com.intellij.openapi.util.Disposer
import java.awt.BorderLayout
class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() {
class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSelection? = null) : PartView() {
constructor(tool: Tool, openFile: (String) -> Unit) : this(tool, openFile, null)
companion object {
fun canRender(tool: Tool): Boolean = tool.name == "plan_exit" && tool.state == ToolExecState.COMPLETED
}
@@ -18,7 +21,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit) : PartView() {
override val contentId: String = tool.id
private var item = tool
private val md = MdViewFactory.create(SessionEditorStyle.current())
private val md = MdViewFactory.create(SessionEditorStyle.current(), selection)
init {
layout = BorderLayout()
@@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.ui.UiStyle
@@ -28,7 +29,8 @@ import javax.swing.Scrollable
class ReasoningView(
reasoning: Reasoning,
openUrl: (String) -> Unit = {},
private val parts: ReasoningParts = reasoningParts(),
selection: SessionSelection? = null,
private val parts: ReasoningParts = reasoningParts(selection),
) :
SecondarySessionPartView(parts.header, parts.scroll) {
@@ -133,8 +135,8 @@ class ReasoningParts(
val icon: JBLabel,
)
private fun reasoningParts(): ReasoningParts {
val md = MdViewFactory.create(SessionEditorStyle.current())
private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts {
val md = MdViewFactory.create(SessionEditorStyle.current(), selection)
val panel = TrackPanel().apply {
isOpaque = true
background = SessionUiStyle.View.surface()
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.client.ui.md.MdViewFactory
@@ -18,11 +19,12 @@ class TextView(
text: Text,
transparent: Boolean = false,
openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
) : PartView() {
override val contentId: String = text.id
val md: MdView = MdViewFactory.create(SessionEditorStyle.current())
val md: MdView = MdViewFactory.create(SessionEditorStyle.current(), selection)
init {
layout = BorderLayout()
@@ -8,6 +8,7 @@ import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.ui.UiStyle
@@ -32,7 +33,11 @@ import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
/** Renders non-read tool calls with VS Code-inspired rows/cards. */
class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) :
class ToolView(
tool: Tool,
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool),
) :
SecondarySessionPartView(parts.header, parts.scroll) {
override val contentId: String = tool.id
@@ -41,6 +46,7 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) :
private var style = SessionEditorStyle.current()
init {
selection?.register(parts.text, this)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
parts.text.text = preview(item)
applyStyle(style)
@@ -147,6 +153,7 @@ class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) :
class ReadToolView(
tool: Tool,
openFile: (String) -> Unit = {},
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool, openFile),
) : SecondarySessionPartView(parts.header, parts.scroll, expandable = false) {
@@ -160,6 +167,7 @@ class ReadToolView(
private var style = SessionEditorStyle.current()
init {
selection?.register(parts.text, this)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
parts.text.text = preview(item)
applyStyle(style)
@@ -322,7 +330,7 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar
val text = JBTextArea().apply {
isEditable = false
caret.isVisible = false
caret.isSelectionVisible = false
caret.isSelectionVisible = true
lineWrap = true
wrapStyleWord = true
foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.ui.SessionLayoutPanel
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.Disposable
@@ -23,6 +24,7 @@ class TurnView(
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
private val selection: SessionSelection? = null,
) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget {
constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current())
@@ -35,7 +37,7 @@ class TurnView(
/** Add a new [MessageView] for [msg] at the end of this turn. */
fun addMessage(msg: Message): MessageView {
val view = MessageView(msg, openFile, style, openUrl)
val view = MessageView(msg, openFile, style, openUrl, selection)
messages[msg.info.id] = view
add(view)
revalidate()
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.views.base.GenericView
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Generic
@@ -21,32 +22,56 @@ import ai.kilocode.client.session.views.todo.TodoWriteView
* 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error.
*/
object ViewFactory {
fun create(
content: Content,
openFile: (String) -> Unit,
): PartView = create(content, openFile, openUrl = {}, selection = null)
fun create(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit,
): PartView = create(content, openFile, openUrl, selection = null)
fun create(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
): PartView = when (content) {
is Text -> TextView(content, openUrl = openUrl)
is Reasoning -> ReasoningView(content, openUrl = openUrl)
is Text -> TextView(content, openUrl = openUrl, selection = selection)
is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection)
is Tool -> when {
TodoWriteView.canRender(content) -> TodoWriteView(content)
PlanExitView.canRender(content) -> PlanExitView(content, openFile)
QuestionResultView.canRender(content) -> QuestionResultView(content)
ReadToolView.canRender(content) -> ReadToolView(content, openFile)
else -> ToolView(content)
PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection)
QuestionResultView.canRender(content) -> QuestionResultView(content, selection)
ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection)
else -> ToolView(content, selection = selection)
}
is Compaction -> CompactionView(content)
is StepFinish -> error("step-finish is timeline-only")
is Generic -> GenericView(content)
}
fun createUser(
content: Content,
openFile: (String) -> Unit,
): PartView = createUser(content, openFile, openUrl = {}, selection = null)
fun createUser(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit,
): PartView = createUser(content, openFile, openUrl, selection = null)
fun createUser(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit = {},
selection: SessionSelection? = null,
): PartView = when (content) {
is Text -> TextView(content, transparent = true, openUrl = openUrl)
else -> create(content, openFile, openUrl)
is Text -> TextView(content, transparent = true, openUrl = openUrl, selection = selection)
else -> create(content, openFile, openUrl, selection)
}
/**
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.RoundedContentPanel
@@ -37,7 +38,9 @@ import javax.swing.JPanel
* Call [setTopPanel], [setHeaderIcon], [setHeader], [setDescription],
* [setContent], [setActions], or [setActionEnabled] to configure the card.
*/
class BaseQuestionView : RoundedContentPanel(
class BaseQuestionView(
private val selection: SessionSelection? = null,
) : RoundedContentPanel(
UiStyle.Gap.lg(),
UiStyle.Gap.pad(),
), SessionEditorStyleTarget {
@@ -352,6 +355,7 @@ class BaseQuestionView : RoundedContentPanel(
alignmentX = Component.LEFT_ALIGNMENT
}
tracked.add(area to bold)
selection?.register(area)
applyFont(area, bold)
return area
}
@@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionRequestState
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
@@ -17,6 +18,8 @@ import ai.kilocode.client.ui.layout.VAlign
import ai.kilocode.client.ui.layout.align
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.ColorUtil
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBHtmlPaneConfiguration
@@ -42,18 +45,20 @@ import javax.swing.text.html.StyleSheet
*/
class PermissionView(
private val reply: (String, PermissionReplyDto) -> Unit,
private val selection: SessionSelection? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
private var requestId: String? = null
private var style = SessionEditorStyle.current()
private val card = BaseQuestionView()
private val card = BaseQuestionView(selection)
private val body = Stack.vertical()
// Track target panes for style updates
private val panes = mutableListOf<JBHtmlPane>()
private val regs = mutableListOf<Disposable>()
private val diffViews = mutableListOf<PermissionDiffView>()
private val ID_DENY = "deny"
@@ -79,6 +84,7 @@ class PermissionView(
card.setHeader(KiloBundle.message("session.permission.title"))
body.removeAll()
disposeRegs()
panes.clear()
diffViews.clear()
@@ -102,6 +108,7 @@ class PermissionView(
fun hideView() {
requestId = null
body.removeAll()
disposeRegs()
panes.clear()
diffViews.clear()
isVisible = false
@@ -158,10 +165,11 @@ class PermissionView(
},
).apply {
isEditable = false
isOpaque = true
this.text = "<html><body><pre>${XmlStringUtil.escapeString(text)}</pre></body></html>"
applyTargetPane(this)
}
isOpaque = true
this.text = "<html><body><pre>${XmlStringUtil.escapeString(text)}</pre></body></html>"
applyTargetPane(this)
selection?.register(this)?.let(regs::add)
}
private fun applyTargetPane(pane: JBHtmlPane) {
pane.font = style.transcriptFont
@@ -244,6 +252,11 @@ class PermissionView(
parent?.repaint()
}
private fun disposeRegs() {
regs.forEach(Disposer::dispose)
regs.clear()
}
// Test helpers
internal fun runButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.run") }
internal fun denyButtonForTest() = buttons(card).first { it.text == KiloBundle.message("session.permission.deny") }
@@ -3,12 +3,15 @@ package ai.kilocode.client.session.views.question
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.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.PartView
import ai.kilocode.client.session.views.ToolView
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
@@ -25,13 +28,14 @@ import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.SwingUtilities
class QuestionResultView(tool: Tool) : PartView() {
class QuestionResultView(tool: Tool, private val selection: SessionSelection? = null) : PartView() {
override val contentId: String = tool.id
private var result = QuestionResultParser.parse(tool) ?: QuestionResult(emptyList(), emptyList())
private var style = SessionEditorStyle.current()
private val texts = mutableListOf<Pair<JBTextArea, Boolean>>()
private val regs = mutableListOf<Disposable>()
private val root = object : JPanel(BorderLayout()) {
override fun updateUI() {
@@ -143,6 +147,11 @@ class QuestionResultView(tool: Tool) : PartView() {
fun titleFont(): Font = title.font
fun subFont(): Font = sub.font
override fun dispose() {
disposeRegs()
texts.clear()
}
override fun dumpLabel(): String = "QuestionResultView#$contentId(${labelText()})"
companion object {
@@ -179,6 +188,7 @@ class QuestionResultView(tool: Tool) : PartView() {
private fun syncBody() {
val panel = pane ?: return
panel.removeAll()
disposeRegs()
texts.clear()
for ((i, q) in result.questions.withIndex()) {
@@ -249,10 +259,16 @@ class QuestionResultView(tool: Tool) : PartView() {
border = JBUI.Borders.empty()
}
texts.add(area to bold)
selection?.register(area)?.let(regs::add)
setFont(area, bold)
return area
}
private fun disposeRegs() {
regs.forEach(Disposer::dispose)
regs.clear()
}
private fun syncArrow() {
arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
}
@@ -7,13 +7,16 @@ import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.ui.SessionView
import ai.kilocode.client.session.ui.editor.SessionEditorTextField
import ai.kilocode.client.session.views.base.BaseQuestionView
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
@@ -48,6 +51,7 @@ class QuestionView(
private val reject: (String) -> Unit,
private val follow: () -> Boolean = { true },
private val scroll: (Boolean) -> Unit = {},
private val selection: SessionSelection? = null,
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
@@ -61,11 +65,12 @@ class QuestionView(
private var customOpen = emptyList<Boolean>()
private var style = SessionEditorStyle.current()
private val texts = mutableListOf<Pair<JBTextArea, Boolean>>()
private val regs = mutableListOf<Disposable>()
// The custom editor for the currently shown question; null when not shown.
private var customEditor: SessionEditorTextField? = null
private var customFocus: FocusAdapter? = null
private val card = BaseQuestionView()
private val card = BaseQuestionView(selection)
private val summary = JBLabel()
private val nav = JPanel().apply {
@@ -145,6 +150,7 @@ class QuestionView(
customOpen = emptyList()
customEditor = null
customFocus = null
disposeRegs()
texts.clear()
body.removeAll()
card.setActions(emptyList())
@@ -169,6 +175,7 @@ class QuestionView(
@RequiresEdt
private fun syncPage() {
val q = question ?: return
disposeRegs()
texts.clear()
customEditor = null
customFocus = null
@@ -693,10 +700,16 @@ class QuestionView(
border = JBUI.Borders.empty()
}
texts.add(area to bold)
selection?.register(area)?.let(regs::add)
setFont(area, bold)
return area
}
private fun disposeRegs() {
regs.forEach(Disposer::dispose)
regs.clear()
}
private fun single(q: Question): Boolean = q.items.size == 1 && !q.items[0].multiple
private fun review(q: Question): Boolean = !single(q) && idx == q.items.size
@@ -1,6 +1,7 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import com.intellij.openapi.Disposable
import java.awt.Color
import java.awt.Font
@@ -15,6 +16,7 @@ interface MdView : Disposable {
fun append(delta: String)
fun clear()
fun applyStyle(style: SessionEditorStyle)
fun setSelection(selection: SessionSelection?)
fun resetStyles()
fun addLinkListener(listener: LinkListener)
fun removeLinkListener(listener: LinkListener)
@@ -1,11 +1,15 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
object MdViewFactory {
fun create(style: SessionEditorStyle = SessionEditorStyle.current()): MdView = hybrid(style)
fun create(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView =
hybrid(style, selection)
fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current()): MdView = MdViewHybrid(style)
fun hybrid(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView =
MdViewHybrid(style, selection)
fun html(style: SessionEditorStyle = SessionEditorStyle.current()): MdView = MdViewHtmlPane(style)
fun html(style: SessionEditorStyle = SessionEditorStyle.current(), selection: SessionSelection? = null): MdView =
MdViewHtmlPane(style, selection)
}
@@ -1,7 +1,10 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.log.KiloLog
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBHtmlPaneConfiguration
import com.intellij.ui.components.JBHtmlPaneStyleConfiguration
@@ -19,6 +22,7 @@ import javax.swing.text.html.StyleSheet
@Suppress("UnstableApiUsage")
internal class MdViewHtmlPane(
style: SessionEditorStyle = SessionEditorStyle.current(),
private var selection: SessionSelection? = null,
) : MdView {
companion object {
private val LOG = KiloLog.create(MdViewHtmlPane::class.java)
@@ -28,6 +32,7 @@ internal class MdViewHtmlPane(
private val source = StringBuilder()
private var rendered = ""
private var style = style
private var reg: Disposable? = null
private var disposed = false
private val extensions = listOf(
@@ -78,6 +83,10 @@ internal class MdViewHtmlPane(
}
}
init {
syncSelection()
}
override val component: JComponent get() = pane
override var font: Font
@@ -195,10 +204,20 @@ internal class MdViewHtmlPane(
if (disposed) return
if (this.style == style) return
this.style = style
selection?.applyStyle(style)
if (opaqueState) pane.background = background
markDirty()
}
override fun setSelection(selection: SessionSelection?) {
if (disposed) return
if (this.selection === selection) return
reg?.let(Disposer::dispose)
reg = null
this.selection = selection
syncSelection()
}
override fun resetStyles() {
if (disposed) return
fontOverride = null
@@ -266,9 +285,15 @@ internal class MdViewHtmlPane(
listeners.clear()
source.clear()
rendered = ""
reg?.let(Disposer::dispose)
reg = null
pane.text = ""
}
private fun syncSelection() {
reg = selection?.register(pane)
}
private fun dispatch(event: MdView.LinkEvent) {
for (l in listeners) l.onLink(event)
}
@@ -1,6 +1,7 @@
package ai.kilocode.client.ui.md
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.log.KiloLog
import com.intellij.openapi.Disposable
@@ -42,6 +43,7 @@ import javax.swing.text.html.StyleSheet
@Suppress("UnstableApiUsage")
internal class MdViewHybrid(
style: SessionEditorStyle = SessionEditorStyle.current(),
private var selection: SessionSelection? = null,
) : MdView {
companion object {
private val LOG = KiloLog.create(MdViewHybrid::class.java)
@@ -274,9 +276,17 @@ internal class MdViewHybrid(
if (disposed) return
if (this.style == style) return
this.style = style
selection?.applyStyle(style)
syncStyle()
}
override fun setSelection(selection: SessionSelection?) {
if (disposed) return
if (this.selection === selection) return
this.selection = selection
syncBlocks()
}
override fun resetStyles() {
if (disposed) return
fontOverride = null
@@ -412,6 +422,7 @@ internal class MdViewHybrid(
isOpaque = opts.opaque
background = opts.background
text = "<html><body>$body</body></html>"
block?.let { selection?.register(this, it) }
addHyperlinkListener { e ->
if (e.eventType != HyperlinkEvent.EventType.ACTIVATED) return@addHyperlinkListener
val href = e.description ?: return@addHyperlinkListener
@@ -427,6 +438,7 @@ internal class MdViewHybrid(
val field = runCatching {
CodeField(file(lang), opts, text).also { ed ->
block?.let { ed.setDisposedWith(it) }
block?.let { selection?.register(ed, it) }
}
}.getOrElse { err ->
LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err)
@@ -502,6 +514,7 @@ internal class MdViewHybrid(
SessionUiStyle.View.Code.VIEWPORT_TOP_PADDING,
SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING,
)
block?.let { selection?.register(this, it) }
}
private inner class CodeField(file: FileType, opts: MdStyle, value: String) :
@@ -0,0 +1,125 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.SessionUiTestBase
import ai.kilocode.client.session.views.ToolView
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.PartDto
import com.intellij.ide.CopyProvider
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.actionSystem.DataMap
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.DataSnapshotProvider
import com.intellij.openapi.actionSystem.PlatformDataKeys
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.ide.CopyPasteManager
import java.awt.Container
import java.awt.datatransfer.DataFlavor
import javax.swing.text.JTextComponent
@Suppress("UnstableApiUsage")
class SessionSelectionCopyTest : SessionUiTestBase() {
fun `test session ui exposes copy provider when selection exists`() {
val area = showTool("alpha output")
select(area, "alpha")
val provider = copyProvider()
assertNotNull(provider)
assertTrue(provider!!.isCopyEnabled(DataContext.EMPTY_CONTEXT))
}
fun `test copy provider writes active selected text`() {
val area = showTool("alpha output")
select(area, "alpha")
copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT)
assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor))
}
fun `test selecting another transcript component changes copied text`() {
val one = showTool("alpha output", id = "tool_a")
val two = showTool("bravo output", id = "tool_b")
select(one, "alpha")
select(two, "bravo")
copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT)
assertNull(one.selectedText)
assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor))
}
private fun select(area: JTextComponent, text: String) {
val start = area.text.indexOf(text)
assertTrue(start >= 0)
area.select(start, start + text.length)
}
private fun showTool(text: String, id: String = "tool_msg"): JTextComponent {
if (controller().id == null) showMessages()
emit(ChatEventDto.MessageUpdated("ses_test", message(id)))
emit(ChatEventDto.PartUpdated(
"ses_test",
PartDto(
id = "part_$id",
sessionID = "ses_test",
messageID = id,
type = "tool",
tool = "bash",
state = "completed",
input = mapOf("command" to "printf"),
output = text,
),
))
for (view in toolViews(ui)) view.expand()
layout()
return textComponent(text)
}
private fun toolViews(root: Container): List<ToolView> {
val out = mutableListOf<ToolView>()
if (root is ToolView) out.add(root)
for (child in root.components) {
if (child is Container) out.addAll(toolViews(child))
}
return out
}
private fun copyProvider(): CopyProvider? {
val sink = CopySink()
(ui as UiDataProvider).uiDataSnapshot(sink)
return sink.copy
}
private fun textComponent(needle: String): JTextComponent = textComponents(ui)
.first { it.text.contains(needle) }
private fun textComponents(root: Container): List<JTextComponent> {
val out = mutableListOf<JTextComponent>()
if (root is JTextComponent) out.add(root)
for (child in root.components) {
if (child is Container) out.addAll(textComponents(child))
}
return out
}
private class CopySink : DataSink {
var copy: CopyProvider? = null
override fun <T : Any> set(key: DataKey<T>, data: T?) {
if (key == PlatformDataKeys.COPY_PROVIDER) copy = data as? CopyProvider
}
override fun <T : Any> setNull(key: DataKey<T>) {}
override fun <T : Any> lazyNull(key: DataKey<T>) {}
override fun <T : Any> lazyValue(key: DataKey<T>, data: (DataMap) -> T?) {}
override fun uiDataSnapshot(provider: UiDataProvider) = provider.uiDataSnapshot(this)
override fun dataSnapshot(provider: DataSnapshotProvider) = provider.dataSnapshot(this)
override fun uiDataSnapshot(provider: DataProvider) {}
}
}
@@ -0,0 +1,72 @@
package ai.kilocode.client.session.ui.selection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBTextArea
import java.awt.event.MouseEvent
@Suppress("UnstableApiUsage")
class SessionSelectionTest : BasePlatformTestCase() {
fun `test selecting second text component clears first`() {
val selection = SessionSelection()
val one = JBTextArea("first value")
val two = JBTextArea("second value")
selection.register(one)
selection.register(two)
one.select(0, 5)
two.select(0, 6)
assertNull(one.selectedText)
assertEquals("second", two.selectedText)
assertEquals("second", selection.selectedText())
}
fun `test clearing active selection disables copy text`() {
val selection = SessionSelection()
val area = JBTextArea("selected value")
selection.register(area)
area.select(0, 8)
area.select(0, 0)
assertNull(selection.selectedText())
}
fun `test starting mouse selection clears previous text component immediately`() {
val selection = SessionSelection()
val one = JBTextArea("first value")
val two = JBTextArea("second value")
selection.register(one)
selection.register(two)
one.select(0, 5)
val event = MouseEvent(two, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(), 0, 1, 1, 1, false)
for (listener in two.mouseListeners) listener.mousePressed(event)
assertNull(one.selectedText)
assertNull(selection.selectedText())
}
fun `test unregistering active selection clears active state`() {
val selection = SessionSelection()
val area = JBTextArea("selected value")
val reg = selection.register(area)
area.select(0, 8)
Disposer.dispose(reg)
assertNull(selection.selectedText())
}
fun `test applyStyle updates swing selection colors`() {
val selection = SessionSelection()
val area = JBTextArea("selected value")
selection.register(area)
selection.applyStyle(SessionEditorStyle.current())
assertNotNull(area.selectionColor)
assertNotNull(area.selectedTextColor)
}
}