From 2a3975ea29acfe4e5c4a4cd293de7e6f9b789e86 Mon Sep 17 00:00:00 2001 From: kirillk Date: Tue, 2 Jun 2026 10:31:45 -0400 Subject: [PATCH] fix(jetbrains): support copying session text --- .changeset/steady-session-selection.md | 5 + .../ai/kilocode/client/session/SessionUi.kt | 33 ++- .../session/ui/SessionMessageListPanel.kt | 6 +- .../session/ui/selection/SessionSelection.kt | 255 ++++++++++++++++++ .../client/session/views/LoginRequiredView.kt | 4 +- .../client/session/views/MessageView.kt | 6 +- .../client/session/views/PlanExitView.kt | 7 +- .../client/session/views/ReasoningView.kt | 8 +- .../kilocode/client/session/views/TextView.kt | 4 +- .../kilocode/client/session/views/ToolView.kt | 12 +- .../kilocode/client/session/views/TurnView.kt | 4 +- .../client/session/views/ViewFactory.kt | 41 ++- .../session/views/base/BaseQuestionView.kt | 6 +- .../views/permission/PermissionView.kt | 23 +- .../views/question/QuestionResultView.kt | 18 +- .../session/views/question/QuestionView.kt | 15 +- .../kotlin/ai/kilocode/client/ui/md/MdView.kt | 2 + .../ai/kilocode/client/ui/md/MdViewFactory.kt | 10 +- .../kilocode/client/ui/md/MdViewHtmlPane.kt | 25 ++ .../ai/kilocode/client/ui/md/MdViewHybrid.kt | 13 + .../session/ui/SessionSelectionCopyTest.kt | 125 +++++++++ .../ui/selection/SessionSelectionTest.kt | 72 +++++ 22 files changed, 658 insertions(+), 36 deletions(-) create mode 100644 .changeset/steady-session-selection.md create mode 100644 packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt create mode 100644 packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt diff --git a/.changeset/steady-session-selection.md b/.changeset/steady-session-selection.md new file mode 100644 index 00000000000..defbe0c352f --- /dev/null +++ b/.changeset/steady-session-selection.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support coherent selection and copy behavior across JetBrains session transcript fragments. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 193d6031864..2a6abcea561 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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(), -) : 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? { + 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 60977888672..d2255359cac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt new file mode 100644 index 00000000000..9a7076951d1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -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() + 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 { + val scheme = style.editorScheme + return (scheme.getColor(EditorColors.SELECTION_BACKGROUND_COLOR) ?: bg ?: style.editorBackground) to + (scheme.getColor(EditorColors.SELECTION_FOREGROUND_COLOR) ?: fg ?: style.editorForeground) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt index f38d72f2600..b45fbb58869 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/LoginRequiredView.kt @@ -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" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index c753a761243..68d48f9d9b3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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]. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt index 70afc6fdf5f..a64af5f93db 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PlanExitView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 790fc88b492..d7835d920f6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index a002af8415f..74e7e1d494f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index b5ad3638aa3..b1736ce7282 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 02d8db97b63..5320b21dbf0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index e3ce38fb78a..09e234c84f4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -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) } /** diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 15e27932ebc..d43a1aa6ae8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index b35fbb9f9c1..acb40df6dca 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -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() + private val regs = mutableListOf() private val diffViews = mutableListOf() 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 = "
${XmlStringUtil.escapeString(text)}
" - applyTargetPane(this) - } + isOpaque = true + this.text = "
${XmlStringUtil.escapeString(text)}
" + 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") } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index 9c0886aab36..32433623fe3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -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>() + private val regs = mutableListOf() 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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index 1c8a77c28db..b0e6ecc8411 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -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() private var style = SessionEditorStyle.current() private val texts = mutableListOf>() + private val regs = mutableListOf() // 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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 7b49c51d716..2ff6770974e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt index e2a5d4c9cdc..259247aeb68 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewFactory.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt index 7644da58530..cfc41fed0d1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHtmlPane.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index 302ad0a8013..ef5c402103c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -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 = "$body" + 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) : diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt new file mode 100644 index 00000000000..0c64f021c9f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -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 { + val out = mutableListOf() + 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 { + val out = mutableListOf() + 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 set(key: DataKey, data: T?) { + if (key == PlatformDataKeys.COPY_PROVIDER) copy = data as? CopyProvider + } + + override fun setNull(key: DataKey) {} + + override fun lazyNull(key: DataKey) {} + + override fun lazyValue(key: DataKey, data: (DataMap) -> T?) {} + + override fun uiDataSnapshot(provider: UiDataProvider) = provider.uiDataSnapshot(this) + override fun dataSnapshot(provider: DataSnapshotProvider) = provider.dataSnapshot(this) + override fun uiDataSnapshot(provider: DataProvider) {} + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt new file mode 100644 index 00000000000..f3d6736a982 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/selection/SessionSelectionTest.kt @@ -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) + } +}