diff --git a/.changeset/jetbrains-hover-copy-overlay.md b/.changeset/jetbrains-hover-copy-overlay.md new file mode 100644 index 00000000000..6f04d4137bd --- /dev/null +++ b/.changeset/jetbrains-hover-copy-overlay.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show a hover copy button for JetBrains session code and tool output blocks. diff --git a/.changeset/jetbrains-message-toolbar.md b/.changeset/jetbrains-message-toolbar.md new file mode 100644 index 00000000000..2bffa7d3be3 --- /dev/null +++ b/.changeset/jetbrains-message-toolbar.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add copy buttons below JetBrains session prompts and assistant responses. diff --git a/.changeset/jetbrains-session-copy.md b/.changeset/jetbrains-session-copy.md new file mode 100644 index 00000000000..a1b74c1739d --- /dev/null +++ b/.changeset/jetbrains-session-copy.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix copying selected text from JetBrains session views. 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 7772ad1dda0..8a47e19af4a 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 @@ -28,6 +28,8 @@ import ai.kilocode.client.session.ui.attachment.attachmentParams import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment import ai.kilocode.client.session.ui.header.SessionHeaderPanel +import ai.kilocode.client.session.ui.selection.SessionContextMenu +import ai.kilocode.client.session.ui.selection.SessionHoverCopyOverlay import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget @@ -142,6 +144,7 @@ class SessionUi( private lateinit var root: SessionRootPanel private lateinit var account: SessionAccountOverlay private lateinit var drop: SessionDropOverlay + private lateinit var overlay: SessionHoverCopyOverlay private val hide = timers.timer(HIDE_MS, repeats = false) { if (disposed || !this::drop.isInitialized) return@timer drop.setActive(false) @@ -171,7 +174,7 @@ class SessionUi( private var modalFocus: (() -> JComponent)? = null private var style = SessionEditorStyle.current() private val selection = SessionSelection() - private val copy = object : TextCopyProvider() { + private val provider = object : TextCopyProvider() { override fun getActionUpdateThread() = ActionUpdateThread.EDT override fun getTextLinesToCopy(): Collection? { @@ -216,7 +219,7 @@ class SessionUi( internal fun currentStyle() = style override fun uiDataSnapshot(sink: DataSink) { - sink[PlatformDataKeys.COPY_PROVIDER] = copy + sink[PlatformDataKeys.COPY_PROVIDER] = provider } @RequiresEdt @@ -253,6 +256,7 @@ class SessionUi( private fun buildUi() { root = SessionRootPanel() + SessionContextMenu.install(root, this) migrationOverlay = MigrationOverlayPanel().apply { onSkip = { migration.skip() } @@ -283,6 +287,11 @@ class SessionUi( ) } + overlay = SessionHoverCopyOverlay(root, this) + root.addOverlay(overlay) { pane, child -> + overlay.bounds(pane, child) + } + sessionContent = JPanel(BorderLayout()) blankBody = JPanel(BorderLayout()).apply { @@ -324,10 +333,12 @@ class SessionUi( header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) + scroll.onScroll = overlay::clear connection = ConnectionPanel(this, controller) prompt = PromptPanel( project = project, + selection = selection, onSend = { text, files -> sendPrompt(text, files) }, onAbort = { controller.abort() }, onEnhance = controller::enhancePrompt, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index 018cea2eb0b..424cfeb62a0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -44,6 +44,7 @@ internal class SessionScroll( internal val bar: JScrollBar get() = component.verticalScrollBar internal val jump: JBLabel val view: JComponent? get() = component.viewport.view as? JComponent + var onScroll: (() -> Unit)? = null private var style = SessionEditorStyle.current() private var tail = true @@ -322,6 +323,7 @@ internal class SessionScroll( val moved = bar.value != value val down = bar.value > value syncValue() + if (moved) onScroll?.invoke() if (auto || opening) { updateJump() return 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 3d7f01029e1..c3b7f65d547 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 @@ -90,25 +90,34 @@ class SessionMessageListPanel( is SessionModelEvent.ContentAdded -> { msgToView[event.messageId]?.upsertPart(event.content) + msgToTurn[event.messageId]?.syncCopyToolbars() refresh() } is SessionModelEvent.ContentUpdated -> { msgToView[event.messageId]?.upsertPart(event.content) + msgToTurn[event.messageId]?.syncCopyToolbars() refresh() } is SessionModelEvent.ContentRemoved -> { msgToView[event.messageId]?.removePart(event.contentId) + msgToTurn[event.messageId]?.syncCopyToolbars() refresh() } is SessionModelEvent.ContentDelta -> { if (event.created) return@addListener val handled = msgToView[event.messageId]?.appendDelta(event.contentId, event.delta) == true - if (handled) return@addListener + if (handled) { + msgToTurn[event.messageId]?.syncCopyToolbars() + return@addListener + } val content = model.content(event.messageId, event.contentId) - if (content != null) msgToView[event.messageId]?.upsertPart(content) + if (content != null) { + msgToView[event.messageId]?.upsertPart(content) + msgToTurn[event.messageId]?.syncCopyToolbars() + } } is SessionModelEvent.HistoryLoaded -> rebuild() @@ -191,6 +200,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(msgId, tv, mv) } + tv.syncCopyToolbars() add(tv) anchorFooter() refresh() @@ -216,6 +226,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(id, tv, mv) } + tv.syncCopyToolbars() refresh() } @@ -248,6 +259,7 @@ class SessionMessageListPanel( val mv = tv.addMessage(msg) register(msgId, tv, mv) } + tv.syncCopyToolbars() add(tv) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt index 366bc5b4801..4b9800b76ba 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/editor/SessionEditorTextField.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.session.ui.editor import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.SendPromptContext +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.actionSystem.DataSink import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.project.Project @@ -24,9 +25,11 @@ import com.intellij.ui.EditorTextField internal open class SessionEditorTextField( project: Project, private val ctx: SendPromptContext? = null, + private val selection: SessionSelection? = null, ) : EditorTextField(project, PlainTextFileType.INSTANCE) { override fun uiDataSnapshot(sink: DataSink) { super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } ctx?.let { sink.set(PromptDataKeys.SEND, it) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt index c5aaa89b63f..3686e36be16 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptEditorTextField.kt @@ -1,9 +1,11 @@ package ai.kilocode.client.session.ui.prompt import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.ui.selection.SessionSelection import com.intellij.openapi.project.Project internal class PromptEditorTextField( project: Project, ctx: SendPromptContext, -) : SessionEditorTextField(project, ctx) + selection: SessionSelection? = null, +) : SessionEditorTextField(project, ctx, selection) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 3c5b790ee4e..633ce6ccfb9 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -13,6 +13,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.mode.ModePicker import ai.kilocode.client.session.ui.model.ModelPicker +import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.iconButton @@ -81,7 +82,8 @@ class PromptPanel( private val onSend: (String, List) -> Unit, private val onAbort: () -> Unit, private val onEnhance: (String, (Result) -> Unit) -> Unit, -) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext { + private val selection: SessionSelection? = null, +) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext, UiDataProvider { companion object { private val LOG = KiloLog.create(PromptPanel::class.java) @@ -124,7 +126,7 @@ class PromptPanel( } } - private val editor = PromptEditorTextField(project, this).apply { + private val editor = PromptEditorTextField(project, this, selection).apply { border = JBUI.Borders.empty() setFontInheritedFromLAF(false) setPlaceholder(placeholder()) @@ -210,6 +212,7 @@ class PromptPanel( init { applyStyle(style) + selection?.register(editor) editor.text = "" editor.addDocumentListener(object : DocumentListener { override fun documentChanged(e: DocumentEvent) { @@ -317,6 +320,10 @@ class PromptPanel( internal val defaultFocusedComponent: JComponent get() = editor + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { editor.text } + } + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt new file mode 100644 index 00000000000..0a34cee1eb2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionContextMenu.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.ide.DataManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.util.Disposer +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.AWTEvent +import java.awt.Component +import java.awt.Point +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.SwingUtilities + +internal object SessionContextMenu { + private val KEY = Any() + private const val ID = "Kilo.Session.ContextMenu" + + @RequiresEdt + fun install(root: JComponent, parent: Disposable) { + if (root.getClientProperty(KEY) == true) return + val listener = AWTEventListener { event -> + val mouse = event as? MouseEvent ?: return@AWTEventListener + if (!mouse.isPopupTrigger) return@AWTEventListener + if (mouse.id != MouseEvent.MOUSE_PRESSED && mouse.id != MouseEvent.MOUSE_RELEASED) return@AWTEventListener + show(root, mouse) + } + Toolkit.getDefaultToolkit().addAWTEventListener(listener, AWTEvent.MOUSE_EVENT_MASK) + root.putClientProperty(KEY, true) + Disposer.register(parent) { + Toolkit.getDefaultToolkit().removeAWTEventListener(listener) + root.putClientProperty(KEY, null) + } + } + + @RequiresEdt + internal fun target(root: JComponent, src: Component, point: Point): Component? = + SessionTargetResolver.target(root, src, point) + + @RequiresEdt + private fun show(root: JComponent, event: MouseEvent) { + if (!root.isShowing) return + val src = event.component ?: return + val target = target(root, src, event.point) ?: return + val group = ActionManager.getInstance().getAction(ID) as? ActionGroup ?: return + val ctx = DataManager.getInstance().getDataContext(target) + val popup = JBPopupFactory.getInstance().createActionGroupPopup( + null, + group, + ctx, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ) + val point = SwingUtilities.convertPoint(src, event.point, target) + popup.show(RelativePoint(target, point)) + event.consume() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyButton.kt new file mode 100644 index 00000000000..519d850cd9d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyButton.kt @@ -0,0 +1,56 @@ +package ai.kilocode.client.session.ui.selection + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.HoverIcon +import com.intellij.icons.AllIcons +import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.awt.RelativePoint +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Cursor +import java.awt.Point +import java.awt.datatransfer.StringSelection +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent + +internal class SessionCopyButton( + fill: Boolean = false, + private val text: () -> String?, +) { + private var balloon: Balloon? = null + val button = HoverIcon(fill = fill).apply { + icon = AllIcons.Actions.Copy + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + toolTipText = KiloBundle.message("session.copy.hover") + } + + init { + button.addActionListener { copy() } + button.addMouseListener(object : MouseAdapter() { + override fun mouseExited(e: MouseEvent) { + dismiss() + } + }) + } + + @RequiresEdt + fun dismiss() { + balloon?.hide() + balloon = null + } + + @RequiresEdt + fun copy() { + val value = text()?.takeIf { it.isNotEmpty() } ?: return + CopyPasteManager.getInstance().setContents(StringSelection(value)) + dismiss() + balloon = JBPopupFactory.getInstance() + .createHtmlTextBalloonBuilder(KiloBundle.message("session.copy.copied"), null, null, null) + .createBalloon() + .also { item -> + item.setAnimationEnabled(false) + item.show(RelativePoint(button, Point(button.width / 2, 0)), Balloon.Position.above) + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt new file mode 100644 index 00000000000..d4528e833c0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionCopyTarget.kt @@ -0,0 +1,11 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.util.concurrency.annotations.RequiresEdt +import javax.swing.JComponent + +internal interface SessionCopyTarget { + val copyAnchor: JComponent + + @RequiresEdt + fun copyText(): String? +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt new file mode 100644 index 00000000000..3b9d55aedd5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionHoverCopyOverlay.kt @@ -0,0 +1,129 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.AWTEvent +import java.awt.Component +import java.awt.Point +import java.awt.Rectangle +import java.awt.Toolkit +import java.awt.event.AWTEventListener +import java.awt.event.MouseEvent +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities + +internal class SessionHoverCopyOverlay( + private val root: JComponent, + parent: Disposable, +) : JPanel(null), Disposable { + private var target: SessionCopyTarget? = null + private val copy = SessionCopyButton(fill = true) { target?.copyText() } + private val button = copy.button + + init { + isVisible = false + isOpaque = false + add(button) + + val listener = AWTEventListener { event -> + val mouse = event as? MouseEvent ?: return@AWTEventListener + when (mouse.id) { + MouseEvent.MOUSE_MOVED, + MouseEvent.MOUSE_DRAGGED, + MouseEvent.MOUSE_EXITED -> sync(mouse) + } + } + Toolkit.getDefaultToolkit().addAWTEventListener( + listener, + AWTEvent.MOUSE_MOTION_EVENT_MASK or AWTEvent.MOUSE_EVENT_MASK, + ) + Disposer.register(parent, this) + Disposer.register(this) { + Toolkit.getDefaultToolkit().removeAWTEventListener(listener) + } + } + + @RequiresEdt + fun bounds(pane: JPanel, child: JComponent): Rectangle { + val item = target ?: return Rectangle() + val anchor = item.copyAnchor + if (!anchor.isShowing || anchor.parent == null) return Rectangle() + val visible = anchor.visibleRect + if (visible.isEmpty) return Rectangle() + val size = child.preferredSize + val gap = JBUI.scale(4) + val pt = SwingUtilities.convertPoint(anchor, Point(visible.x + visible.width, visible.y), pane) + val x = (pt.x - size.width - gap).coerceIn(0, (pane.width - size.width).coerceAtLeast(0)) + val y = (pt.y + gap).coerceIn(0, (pane.height - size.height).coerceAtLeast(0)) + return Rectangle(x, y, size.width, size.height) + } + + override fun doLayout() { + button.setBounds(0, 0, width, height) + } + + override fun getPreferredSize() = button.preferredSize + + override fun getMinimumSize() = button.minimumSize + + override fun getMaximumSize() = button.maximumSize + + @RequiresEdt + private fun sync(event: MouseEvent) { + val src = event.component ?: return conceal() + if (SessionTargetResolver.inside(this, src)) return retain() + if (contains(target, src, event.point)) return + val item = SessionTargetResolver.copy(root, src, event.point, this) + if (item == null) { + conceal() + return + } + show(item) + } + + @RequiresEdt + private fun show(item: SessionCopyTarget) { + if (target === item && isVisible) return + target = item + isVisible = true + parent?.doLayout() + revalidate() + repaint() + } + + @RequiresEdt + private fun retain() { + if (target == null || isVisible) return + isVisible = true + } + + @RequiresEdt + internal fun contains(item: SessionCopyTarget?, src: Component, point: Point): Boolean { + val anchor = item?.copyAnchor ?: return false + if (!SessionTargetResolver.inside(anchor, src)) return false + val pt = SwingUtilities.convertPoint(src, point, anchor) + return anchor.contains(pt) + } + + @RequiresEdt + fun clear() { + conceal() + } + + @RequiresEdt + private fun conceal() { + copy.dismiss() + if (target == null && !isVisible) return + target = null + isVisible = false + revalidate() + repaint() + } + + override fun dispose() { + clear() + } +} 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 index 0f7fb1a14b0..32b1c740dce 100644 --- 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 @@ -1,7 +1,11 @@ package ai.kilocode.client.session.ui.selection import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.ide.TextCopyProvider import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.event.SelectionEvent import com.intellij.openapi.editor.event.SelectionListener @@ -23,6 +27,7 @@ class SessionSelection : Disposable { private var style: SessionEditorStyle? = null private var clearing = false private var disposed = false + private val copy = provider { selectedText() } @RequiresEdt fun selectedText(): String? { @@ -37,6 +42,24 @@ class SessionSelection : Disposable { return item.selectedText()?.takeIf { it.isNotEmpty() } } + @RequiresEdt + fun provideCopy(sink: DataSink, content: (() -> String?)? = null) { + if (content == null) { + sink.set(PlatformDataKeys.COPY_PROVIDER, copy) + return + } + sink.set(PlatformDataKeys.COPY_PROVIDER, provider { selectedText() ?: content() }) + } + + private fun provider(text: () -> String?) = object : TextCopyProvider() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun getTextLinesToCopy(): Collection? { + val item = text()?.takeIf { it.isNotEmpty() } ?: return null + return listOf(item) + } + } + @RequiresEdt fun register(component: JTextComponent, parent: Disposable? = null): Disposable { val item = TextItem(component) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionTargetResolver.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionTargetResolver.kt new file mode 100644 index 00000000000..5c1615304d3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionTargetResolver.kt @@ -0,0 +1,74 @@ +package ai.kilocode.client.session.ui.selection + +import com.intellij.openapi.actionSystem.UiDataProvider +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.Component +import java.awt.Container +import java.awt.Point +import javax.swing.JComponent +import javax.swing.SwingUtilities + +internal object SessionTargetResolver { + @RequiresEdt + fun target(root: JComponent, src: Component, point: Point, skip: Component? = null): Component? { + if (!inside(root, src)) return null + val pt = SwingUtilities.convertPoint(src, point, root) + if (!root.contains(pt)) return null + val deep = deepest(root, pt, skip) ?: src + return provider(root, deep) ?: deep + } + + @RequiresEdt + fun copy(root: JComponent, src: Component, point: Point, skip: Component? = null): SessionCopyTarget? { + if (!inside(root, src)) return null + val pt = SwingUtilities.convertPoint(src, point, root) + if (!root.contains(pt)) return null + return copy(root, deepest(root, pt, skip) ?: src) + } + + @RequiresEdt + internal fun inside(root: JComponent, comp: Component): Boolean = comp === root || SwingUtilities.isDescendingFrom(comp, root) + + @RequiresEdt + private fun deepest(root: JComponent, pt: Point, skip: Component?): Component? { + if (skip == null || !skip.isVisible) { + return SwingUtilities.getDeepestComponentAt(root, pt.x, pt.y)?.takeIf { inside(root, it) } + } + return deepestSkipping(root, pt, skip) + } + + @RequiresEdt + private fun deepestSkipping(container: Container, pt: Point, skip: Component): Component? { + for (child in container.components.toList().asReversed()) { + if (child === skip || SwingUtilities.isDescendingFrom(child, skip)) continue + if (!child.isVisible || !child.contains(SwingUtilities.convertPoint(container, pt, child))) continue + if (child is Container) { + val next = deepestSkipping(child, SwingUtilities.convertPoint(container, pt, child), skip) + if (next != null) return next + } + return child + } + return if (container === skip) null else container + } + + @RequiresEdt + private fun provider(root: JComponent, comp: Component): Component? { + var current: Component? = comp + while (current != null && inside(root, current)) { + if (current is UiDataProvider) return current + current = current.parent + } + return null + } + + @RequiresEdt + private fun copy(root: JComponent, comp: Component): SessionCopyTarget? { + var current: Component? = comp + var target: SessionCopyTarget? = null + while (current != null && inside(root, current)) { + if (current is SessionCopyTarget) target = current + current = current.parent + } + return target + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index a0ae7a2dfe8..19c76b825d3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -40,8 +40,13 @@ data class SessionEditorStyle( ) { /** Apply this snapshot to embedded IntelliJ editor components used by session UI. */ fun applyToEditor(editor: EditorEx) { - editor.setColorsScheme(editorScheme) - editor.setFontSize(editorSize) + try { + if (editor.isDisposed) return + editor.setColorsScheme(editorScheme) + editor.setFontSize(editorSize) + } catch (err: RuntimeException) { + if (err.javaClass.name != "com.intellij.openapi.util.TraceableDisposable\$DisposalException") throw err + } } companion object { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt new file mode 100644 index 00000000000..b32eb166b94 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.ui.selection.SessionCopyButton +import com.intellij.util.concurrency.annotations.RequiresEdt +import java.awt.BorderLayout +import java.awt.Graphics +import javax.swing.JPanel + +internal class MessageToolbar( + private val align: String = BorderLayout.LINE_START, + private val text: () -> String?, +) : JPanel(BorderLayout()) { + private val copy = SessionCopyButton(text = text) + private val button = copy.button + + init { + isOpaque = false + add(button, align) + } + + @RequiresEdt + fun sync(value: Boolean) { + if (isVisible == value && button.isEnabled == value) return + isVisible = value + button.isEnabled = value + revalidate() + repaint() + } + + @RequiresEdt + fun paint(value: Boolean) { + // Prompt toolbars stay visible to reserve layout space while their button is visually hidden. + if (!isVisible) isVisible = true + if (button.isEnabled == value) return + button.isEnabled = value + repaint() + } + + @RequiresEdt + fun paints() = button.isEnabled + + @RequiresEdt + fun alignment() = align + + @RequiresEdt + fun copyButton() = button + + override fun removeNotify() { + copy.dismiss() + super.removeNotify() + } + + override fun paintComponent(g: Graphics) { + if (!button.isEnabled) return + super.paintComponent(g) + } + + override fun paintChildren(g: Graphics) { + if (!button.isEnabled) return + super.paintChildren(g) + } +} 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 8506d867923..a9544308018 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 @@ -18,10 +18,17 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import java.awt.Point import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.Container import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.SwingUtilities /** * A single message container inside a [TurnView]. @@ -63,11 +70,26 @@ class MessageView( private val sources = LinkedHashMap() private var attachments: PromptAttachmentView? = null private var hidden: ToolCallRef? = null + private var prompt: PromptView? = null + private var promptBox: JPanel? = null + private var promptToolbar: MessageToolbar? = null + private var promptHover = false init { isOpaque = false if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground border = assistantBorder() + if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { + addMouseListener(object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + setPromptHovered(true) + } + + override fun mouseExited(e: MouseEvent) { + setPromptHovered(false) + } + }) + } // Populate content that already exists (e.g. after loadHistory) for ((_, content) in msg.parts) { @@ -88,6 +110,7 @@ class MessageView( } /** Add or update the renderer for [content]. */ + @RequiresEdt fun upsertPart(content: Content) { if (content is StepFinish) return if (isHidden(content)) { @@ -126,6 +149,7 @@ class MessageView( return } existing.update(content) + syncPromptToolbar() refresh() return } @@ -134,6 +158,7 @@ class MessageView( refresh() } + @RequiresEdt private fun addPart(content: Content) { if (content is FileAttachment && role == SessionUiStyle.View.Message.USER_ROLE) { addAttachment(content) @@ -149,11 +174,12 @@ class MessageView( } } val view = view(content) + val item = wrapPrompt(view) view.resize = resize view.hover = hover view.applyStyle(style) parts[content.id] = view - add(view) + add(item) } @RequiresEdt @@ -169,6 +195,7 @@ class MessageView( parts[content.id] = view } + @RequiresEdt private fun updateAlias(content: Reasoning, id: String) { val view = parts[id] as? ReasoningView ?: return val prev = sources[content.id].orEmpty() @@ -185,6 +212,7 @@ class MessageView( it.content.append(delta) } + @RequiresEdt private fun replacePart(content: Content, existing: PartView) { val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) @@ -194,16 +222,18 @@ class MessageView( remove(existing) Disposer.dispose(existing) val view = view(content) + val item = wrapPrompt(view) view.resize = resize view.hover = hover view.applyStyle(style) parts[content.id] = view - add(view, at) + add(item, at) syncBorder() refresh() } /** Remove the renderer for [contentId] if present. */ + @RequiresEdt fun removePart(contentId: String) { if (aliases.remove(contentId) != null) { sources.remove(contentId) @@ -246,6 +276,7 @@ class MessageView( * Clear and rebuild all part views from [msg.parts]. * Called only when the hidden ref changes to avoid unnecessary rebuilds. */ + @RequiresEdt private fun rebuildParts() { parts.values.distinct().forEach { detach(it) @@ -256,6 +287,10 @@ class MessageView( aliases.clear() sources.clear() attachments = null + prompt = null + promptBox = null + promptToolbar = null + promptHover = false for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue @@ -265,6 +300,7 @@ class MessageView( refresh() } + @RequiresEdt private fun syncBorder() { if (msg.info.role != SessionUiStyle.View.Message.ASSISTANT_ROLE) return border = assistantBorder() @@ -277,14 +313,33 @@ class MessageView( } /** Append a streaming delta to the renderer for [contentId]. */ + @RequiresEdt fun appendDelta(contentId: String, delta: String): Boolean { val id = aliases[contentId] if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta val part = parts[id ?: contentId] ?: return false part.appendDelta(delta) + syncPromptToolbar() return true } + @RequiresEdt + fun syncCopyToolbar(copyId: String?) { + if (role == SessionUiStyle.View.Message.USER_ROLE) return + for ((id, view) in parts) { + if (view is TextView) view.setCopyToolbar(id == copyId) + } + } + + @RequiresEdt + fun latestAssistantCopyId(): String? { + if (role != SessionUiStyle.View.Message.ASSISTANT_ROLE) return null + for ((id, view) in parts.entries.reversed()) { + if (view is TextView && view.markdown().isNotBlank()) return id + } + return null + } + /** Look up a renderer by part id. */ fun part(id: String): PartView? = parts[aliases[id] ?: id] @@ -294,6 +349,21 @@ class MessageView( /** Compact dump for test assertions. */ fun dump(): String = parts.values.joinToString(", ") { it.dumpLabel() } + @RequiresEdt + fun setPromptHovered(value: Boolean) { + if (role != SessionUiStyle.View.Message.USER_ROLE) return + if (promptHover == value) return + promptHover = value + syncPromptToolbar() + } + + @RequiresEdt + fun paintsPromptToolbar() = promptToolbar?.paints() == true + + @RequiresEdt + fun promptToolbarAlignment() = promptToolbar?.alignment() + + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground @@ -301,6 +371,7 @@ class MessageView( refresh() } + @RequiresEdt override fun dispose() { parts.values.forEach { detach(it) @@ -310,6 +381,10 @@ class MessageView( parts.clear() aliases.clear() sources.clear() + prompt = null + promptBox = null + promptToolbar = null + promptHover = false hidden = null } @@ -318,31 +393,102 @@ class MessageView( super.paintComponent(g) return } + val box = promptBox + if (box != null) { + paintPromptBox(g, box) + super.paintComponent(g) + return + } + paintPromptBox(g, this) + super.paintComponent(g) + } + + private fun paintPromptBox(g: Graphics, box: JComponent) { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) + val pt = if (box === this) Point() else SwingUtilities.convertPoint(box, Point(), this) + val x = pt.x + val y = pt.y + val w = box.width - 1 + val h = box.height - 1 g2.color = style.editorScheme.defaultBackground - g2.fillRoundRect(0, 0, width, height, arc, arc) + g2.fillRoundRect(x, y, box.width, box.height, arc, arc) g2.color = SessionUiStyle.View.Outline.color() - val w = width - 1 - val h = height - 1 - if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc) + if (w > 0 && h > 0) g2.drawRoundRect(x, y, w, h, arc, arc) } finally { g2.dispose() } - super.paintComponent(g) } + @RequiresEdt private fun refresh() { revalidate() repaint() } + @RequiresEdt private fun detach(view: PartView) { view.setHovered(false) view.hover = null } + @RequiresEdt + private fun syncPromptToolbar() { + promptToolbar?.paint(promptHover) + } + + @RequiresEdt + private fun wrapPrompt(view: PartView): JComponent { + if (role != SessionUiStyle.View.Message.USER_ROLE) return view + if (view !is PromptView) return view + prompt = view + val bar = promptToolbar ?: MessageToolbar(BorderLayout.LINE_END) { prompt?.copyMarkdown(trim = false) }.also { promptToolbar = it } + val box = JPanel(BorderLayout()).also { + it.isOpaque = false + it.add(view, BorderLayout.CENTER) + promptBox = it + } + bar.paint(false) + return JPanel(BorderLayout()).also { + it.isOpaque = false + it.add(box, BorderLayout.CENTER) + it.add(bar, BorderLayout.SOUTH) + installPromptHover(it) + } + } + + @RequiresEdt + private fun installPromptHover(root: JComponent) { + val mouse = object : MouseAdapter() { + override fun mouseEntered(e: MouseEvent) { + setPromptHovered(true) + } + + override fun mouseExited(e: MouseEvent) { + val point = root.mousePosition + if (point != null && root.contains(point)) return + if (inside(root, e)) return + setPromptHovered(false) + } + } + visit(root) { it.addMouseListener(mouse) } + } + + @RequiresEdt + private fun inside(root: JComponent, e: MouseEvent): Boolean { + val point = SwingUtilities.convertPoint(e.component, e.point, root) + return root.contains(point) + } + + @RequiresEdt + private fun visit(root: Container, fn: (JComponent) -> Unit) { + if (root is JComponent) fn(root) + for (child in root.components) { + if (child is Container) visit(child, fn) + } + } + private fun assistantBorder() = JBUI.Borders.empty() } 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 8b5c30eee0f..6897e22af68 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 @@ -8,7 +8,9 @@ import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import java.awt.BorderLayout +import javax.swing.JButton /** * Renders a [Text] part as markdown using [MdView]. @@ -25,6 +27,8 @@ open class TextView( override val contentId: String = text.id val md: MdView = MdViewFactory.create(SessionEditorStyle.current(), selection) + private var mode: CopyMode? = null + private val toolbar = MessageToolbar { copyText() } init { layout = BorderLayout() @@ -34,21 +38,43 @@ open class TextView( md.addLinkListener { openUrl(it.href) } applyStyle(SessionEditorStyle.current()) add(md.component, BorderLayout.CENTER) + add(toolbar, BorderLayout.SOUTH) if (text.content.isNotEmpty()) md.set(text.content.toString()) + syncToolbar() } override fun update(content: Content) { if (content !is Text) return md.set(content.content.toString()) + syncToolbar() refresh() } override fun appendDelta(delta: String) { if (delta.isEmpty()) return md.append(delta) + syncToolbar() refresh() } + @RequiresEdt + fun setCopyToolbar(enabled: Boolean, trim: Boolean = true) { + mode = if (enabled) CopyMode(trim) else null + syncToolbar() + } + + @RequiresEdt + fun hasCopyToolbar() = toolbar.isVisible + + @RequiresEdt + fun copyButton(): JButton = toolbar.copyButton() + + @RequiresEdt + fun copyMarkdown(trim: Boolean = true): String { + val text = md.markdown() + return if (trim) text.trim() else text + } + /** Current markdown source — used by tests to assert rendered content. */ fun markdown(): String = md.markdown() @@ -79,5 +105,18 @@ open class TextView( repaint() } + @RequiresEdt + private fun syncToolbar() { + toolbar.sync(copyText()?.isNotEmpty() == true) + } + + @RequiresEdt + private fun copyText(): String? { + val item = mode ?: return null + return copyMarkdown(item.trim) + } + override fun dumpLabel() = "TextView#$contentId" + + private data class CopyMode(val trim: Boolean) } 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 e00944d1282..2723c754bde 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 @@ -10,6 +10,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import javax.swing.JComponent @@ -47,6 +48,7 @@ class TurnView( val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover) messages[msg.info.id] = view add(view) + syncCopyToolbars() revalidate() return view } @@ -56,9 +58,16 @@ class TurnView( val view = messages.remove(msgId) ?: return remove(view) Disposer.dispose(view) + syncCopyToolbars() revalidate() } + @RequiresEdt + fun syncCopyToolbars() { + val id = messages.values.reversed().firstNotNullOfOrNull { it.latestAssistantCopyId() } + for (view in messages.values) view.syncCopyToolbar(id) + } + /** Look up a nested [MessageView] by message id. */ fun messageView(id: String): MessageView? = messages[id] @@ -71,6 +80,7 @@ class TurnView( override fun applyStyle(style: SessionEditorStyle) { this.style = style for (view in messages.values) view.applyStyle(style) + syncCopyToolbars() revalidate() repaint() } 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 42c61bdb25d..d4e04515aae 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 @@ -488,7 +488,7 @@ class QuestionView( */ @RequiresEdt private fun buildCustomEditor(): SessionEditorTextField { - val ed = SessionEditorTextField(project) + val ed = SessionEditorTextField(project, selection = selection) ed.border = JBUI.Borders.empty() ed.setFontInheritedFromLAF(false) ed.setPlaceholder(KiloBundle.message("session.question.custom.placeholder")) @@ -506,6 +506,7 @@ class QuestionView( ex.settings.isAdditionalPageAtBottom = false ex.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER } + selection?.register(ed)?.let(regs::add) ed.font = style.editorFont ed.background = style.editorScheme.defaultBackground diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt index 7200420d4ea..2593c941a08 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ShellToolView.kt @@ -13,6 +13,8 @@ import ai.kilocode.client.ui.md.MdCodeBlockFactory import ai.kilocode.client.ui.md.MdCodeBlockOptions import ai.kilocode.client.ui.md.MdViewFactory import ai.kilocode.client.ui.md.hybrid.MdTerminal +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField @@ -26,10 +28,10 @@ import javax.swing.ScrollPaneConstants class ShellToolView( tool: Tool, - selection: SessionSelection? = null, + private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool), private val holder: ShellHolder = ShellHolder(tool, selection), -) : SecondarySessionPartView(parts.header, { holder.body().panel }) { +) : SecondarySessionPartView(parts.header, { holder.body().panel }), UiDataProvider { override val contentId: String = tool.id @@ -43,6 +45,12 @@ class ShellToolView( sync() } + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { holder.shell?.markdown() ?: fallbackText() } + } + + private fun fallbackText() = ShellContent(item).body + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt index 0dd50b8932e..5b0654b56e1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.SessionViewIcons @@ -16,6 +17,8 @@ import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.cli.KiloCliParser import ai.kilocode.log.KiloLog +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileTypes.PlainTextFileType @@ -164,10 +167,14 @@ class ToolBody private constructor( fun register(selection: SessionSelection, parent: Disposable) { val field = ed if (field != null) { + (field as? ToolField)?.selection = selection selection.register(field, parent) return } - area?.let { selection.register(it, parent) } + area?.let { + (it as? ToolArea)?.selection = selection + selection.register(it, parent) + } } @RequiresEdt @@ -233,7 +240,7 @@ class ToolBody private constructor( return body } - private fun area(tool: Tool, wrap: Boolean) = JBTextArea().apply { + private fun area(tool: Tool, wrap: Boolean) = ToolArea().apply { isEditable = false caret.isVisible = false caret.isSelectionVisible = true @@ -272,13 +279,29 @@ class ToolBody private constructor( } } +private class ToolArea : JBTextArea(), UiDataProvider, SessionCopyTarget { + var selection: SessionSelection? = null + override val copyAnchor: JComponent get() = this + + override fun copyText() = text + + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { copyText() } + } +} + private class ToolField(value: String, private var style: SessionEditorStyle) : EditorTextField( EditorFactory.getInstance().createDocument(value.trimEnd('\n')), ProjectManager.getInstance().defaultProject, PlainTextFileType.INSTANCE, true, false, -) { +), SessionCopyTarget { + var selection: SessionSelection? = null + override val copyAnchor: JComponent get() = this + + override fun copyText() = text + init { setFontInheritedFromLAF(false) font = style.editorFont @@ -296,6 +319,11 @@ private class ToolField(value: String, private var style: SessionEditorStyle) : ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER } } + + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { copyText() } + } } private const val SUB_CARD = "sub" diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt index 97324bccd2b..1f04865526f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -8,6 +8,8 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.util.Disposer import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI @@ -19,7 +21,7 @@ class ToolView( tool: Tool, private val selection: SessionSelection? = null, private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), -) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }), UiDataProvider { override val contentId: String = tool.id @@ -34,6 +36,12 @@ class ToolView( sync() } + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { parts.content?.text ?: fallbackText() } + } + + private fun fallbackText() = listOf(commandText(), outputText()).filter { it.isNotBlank() }.joinToString("\n\n") + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt index 70f8b27af8e..8a2c945b93d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -9,7 +9,7 @@ import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import javax.swing.JButton -class HoverIcon : JButton() { +class HoverIcon(private val fill: Boolean = false) : JButton() { private var over = false init { @@ -32,7 +32,7 @@ class HoverIcon : JButton() { override fun getMaximumSize(): Dimension = preferredSize override fun paintComponent(g: Graphics) { - if (isEnabled && over) paintHover(g) + if (isEnabled && (over || fill)) paintHover(g) super.paintComponent(g) } @@ -40,9 +40,19 @@ class HoverIcon : JButton() { val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground() + val base = UiStyle.Colors.bg() + val hover = UiStyle.Colors.actionHoverBackground() + g2.color = when { + over && fill -> UiStyle.Colors.blend(base, hover, hover.alpha / 255f) + over -> hover + else -> base + } val arc = JBUI.scale(JBUI.getInt("Button.arc", 6)) g2.fillRoundRect(0, 0, width, height, arc, arc) + if (fill) { + g2.color = UiStyle.Colors.contentBorder() + g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc) + } } finally { g2.dispose() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index 0e1c0b047d5..d2f24bb0f78 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -130,6 +130,8 @@ object UiStyle { fun infoOverlayBorder(): Color = JBUI.CurrentTheme.NotificationInfo.borderColor() + fun actionHoverBackground(): Color = JBUI.CurrentTheme.ActionButton.hoverBackground() + fun errorOverlayBackground(): Color = JBUI.CurrentTheme.NotificationError.backgroundColor() fun errorOverlayForeground(): Color = JBUI.CurrentTheme.NotificationError.foregroundColor() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt index d27b2b62327..cfb9c4b0bcf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/hybrid/MdViewHybrid.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.ui.md.hybrid import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.selection.SessionCopyTarget import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.md.MdCodeBlockBorder import ai.kilocode.client.ui.md.MdCodeBlockFactory @@ -11,6 +12,8 @@ import ai.kilocode.client.ui.md.MdView import ai.kilocode.log.KiloLog import com.intellij.execution.ui.ConsoleViewContentType import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.markup.HighlighterLayer import com.intellij.openapi.editor.markup.HighlighterTargetArea @@ -93,7 +96,7 @@ internal open class MdViewHybrid( private var tableBorderOverride: Color? = null private var opaqueState = true - private val root = JPanel().apply { + private val root = RootPanel().apply { layout = BoxLayout(this, BoxLayout.Y_AXIS) isOpaque = true background = opts().background @@ -407,7 +410,7 @@ internal open class MdViewHybrid( private fun htmlBlock(body: String, disposable: Disposable): JBHtmlPane { val opts = opts() - return JBHtmlPane( + return object : JBHtmlPane( JBHtmlPaneStyleConfiguration { enableInlineCodeBackground = true enableCodeBlocksBackground = true @@ -415,7 +418,11 @@ internal open class MdViewHybrid( JBHtmlPaneConfiguration { customStyleSheetProvider { sheet() } }, - ).apply { + ), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { document.getText(0, document.length).trim() } + } + }.apply { isEditable = false isOpaque = opts.opaque background = opts.background @@ -454,7 +461,15 @@ internal open class MdViewHybrid( } } sizeCodeField(field, value) - val pane = object : JBScrollPane(field) { + val pane = object : JBScrollPane(field), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = when (field) { + is CodeField -> field.text + is JBTextArea -> field.text + else -> "" + } + override fun doLayout() { super.doLayout() if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return @@ -481,7 +496,11 @@ internal open class MdViewHybrid( selection?.register(ed, disposable) } sizeCodeField(field, value.text) - val pane = object : JBScrollPane(field) { + val pane = object : JBScrollPane(field), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = field.text + override fun doLayout() { super.doLayout() if (code.opts.verticalPolicy != ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER) return @@ -585,7 +604,11 @@ internal open class MdViewHybrid( return line * rows } - private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = JBTextArea(text.trimEnd('\n')).apply { + private fun textArea(text: String, opts: MdStyle, disposable: Disposable) = object : JBTextArea(text.trimEnd('\n')), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = this.text + }.apply { isEditable = false lineWrap = false styleTextArea(this, opts) @@ -610,7 +633,11 @@ internal open class MdViewHybrid( file, true, false, - ) { + ), SessionCopyTarget { + override val copyAnchor: JComponent get() = this + + override fun copyText() = text + init { setFontInheritedFromLAF(false) font = style.editorFont @@ -630,6 +657,17 @@ internal open class MdViewHybrid( ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER } } + + override fun uiDataSnapshot(sink: DataSink) { + super.uiDataSnapshot(sink) + selection?.provideCopy(sink) { text } + } + } + + private inner class RootPanel : JPanel(), UiDataProvider { + override fun uiDataSnapshot(sink: DataSink) { + selection?.provideCopy(sink) { markdown() } + } } private fun shellDisplay(term: Term, mode: Mode): ShellDisplay { diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index b7515a2cd2d..493c4cc4268 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -158,5 +158,9 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 9cef2cc9897..492a4429927 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -20,6 +20,8 @@ feedback.dialog.discord=Join our Discord community feedback.dialog.support=Customer Support session.scroll.bottom=Scroll to bottom session.scroll.question=Scroll to question +session.copy.hover=Copy +session.copy.copied=Copied session.drop.files.title=Drop files here session.drop.files.subtitle=to add them to the prompt session.tab.new=New Session @@ -318,6 +320,7 @@ action.Kilo.Session.Rename.description=Rename the selected session action.Kilo.Session.Delete.text=Delete action.Kilo.Session.Delete.description=Delete the selected session(s) action.Kilo.History.ContextMenu.text=History Actions +action.Kilo.Session.ContextMenu.text=Session Actions # Migration wizard migration.migrate.title=Migrate Your Settings diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 3544c8eec61..39a29c4249a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -15,6 +15,8 @@ feedback.dialog.github=الإبلاغ عن مشكلة على GitHub feedback.dialog.discord=الانضمام إلى مجتمع Discord feedback.dialog.support=دعم العملاء session.scroll.bottom=التمرير إلى الأسفل +session.copy.hover=نسخ +session.copy.copied=تم النسخ session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=إعادة تسمية الجلسة الم action.Kilo.Session.Delete.text=حذف action.Kilo.Session.Delete.description=حذف الجلسة (الجلسات) المحددة action.Kilo.History.ContextMenu.text=إجراءات السجل +action.Kilo.Session.ContextMenu.text=إجراءات الجلسة notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index fae54dee56f..280b65d437c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Prijavite problem na GitHubu feedback.dialog.discord=Pridružite se našoj Discord zajednici feedback.dialog.support=Korisnička podrška session.scroll.bottom=Skrolaj na dno +session.copy.hover=Kopiraj +session.copy.copied=Kopirano session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Preimenujte odabranu sesiju action.Kilo.Session.Delete.text=Obriši action.Kilo.Session.Delete.description=Obrišite odabrane sesije action.Kilo.History.ContextMenu.text=Akcije historije +action.Kilo.Session.ContextMenu.text=Radnje sesije notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index fddcf9a1386..b8540bb70e8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Rapportér et problem på GitHub feedback.dialog.discord=Deltag i vores Discord-fællesskab feedback.dialog.support=Kundesupport session.scroll.bottom=Rul til bunden +session.copy.hover=Kopiér +session.copy.copied=Kopieret session.tab.new=Ny session session.tab.untitled=Unavngivet session @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Omdøb den valgte session action.Kilo.Session.Delete.text=Slet action.Kilo.Session.Delete.description=Slet den/de valgte session(er) action.Kilo.History.ContextMenu.text=Historikhandlinger +action.Kilo.Session.ContextMenu.text=Sessionshandlinger notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index f3611158bd6..9ff4d5868ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Ein Problem auf GitHub melden feedback.dialog.discord=Unserer Discord-Community beitreten feedback.dialog.support=Kundensupport session.scroll.bottom=Zum Ende scrollen +session.copy.hover=Kopieren +session.copy.copied=Kopiert session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Ausgewählte Sitzung umbenennen action.Kilo.Session.Delete.text=Löschen action.Kilo.Session.Delete.description=Ausgewählte Sitzung(en) löschen action.Kilo.History.ContextMenu.text=Verlaufsaktionen +action.Kilo.Session.ContextMenu.text=Sitzungsaktionen notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index fb5bd1f0f0c..061266a1417 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Reportar un problema en GitHub feedback.dialog.discord=Unirse a nuestra comunidad de Discord feedback.dialog.support=Atención al cliente session.scroll.bottom=Desplazarse al final +session.copy.hover=Copiar +session.copy.copied=Copiado session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Renombrar la sesión seleccionada action.Kilo.Session.Delete.text=Eliminar action.Kilo.Session.Delete.description=Eliminar la(s) sesión(es) seleccionada(s) action.Kilo.History.ContextMenu.text=Acciones del historial +action.Kilo.Session.ContextMenu.text=Acciones de sesión notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index e39fc31fa58..ae5d807b587 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Signaler un problème sur GitHub feedback.dialog.discord=Rejoindre notre communauté Discord feedback.dialog.support=Service client session.scroll.bottom=Faire défiler vers le bas +session.copy.hover=Copier +session.copy.copied=Copié session.tab.new=Nouvelle session session.tab.untitled=Session sans titre @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Renommer la session sélectionnée action.Kilo.Session.Delete.text=Supprimer action.Kilo.Session.Delete.description=Supprimer la ou les sessions sélectionnées action.Kilo.History.ContextMenu.text=Actions d'historique +action.Kilo.Session.ContextMenu.text=Actions de session notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 1d55ef6d1d4..15903cd9bcf 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -15,6 +15,8 @@ feedback.dialog.github=GitHubで問題を報告する feedback.dialog.discord=Discordコミュニティに参加する feedback.dialog.support=カスタマーサポート session.scroll.bottom=一番下にスクロール +session.copy.hover=コピー +session.copy.copied=コピーしました session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=選択したセッションを変名 action.Kilo.Session.Delete.text=削除 action.Kilo.Session.Delete.description=選択したセッションを削除 action.Kilo.History.ContextMenu.text=履歴アクション +action.Kilo.Session.ContextMenu.text=セッション操作 notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index e4e0edd6f68..11e6f17224c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -15,6 +15,8 @@ feedback.dialog.github=GitHub에 이슈 보고하기 feedback.dialog.discord=Discord 커뮤니티 참여하기 feedback.dialog.support=고객 지원 session.scroll.bottom=맨 아래로 스크롤 +session.copy.hover=복사 +session.copy.copied=복사됨 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=선택한 세션 이름 바꾸기 action.Kilo.Session.Delete.text=삭제 action.Kilo.Session.Delete.description=선택한 세션 삭제 action.Kilo.History.ContextMenu.text=기록 작업 +action.Kilo.Session.ContextMenu.text=세션 작업 notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index e42aeae534a..73868343a00 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Meld een probleem op GitHub feedback.dialog.discord=Word lid van onze Discord community feedback.dialog.support=Klantenservice session.scroll.bottom=Naar beneden scrollen +session.copy.hover=Kopiëren +session.copy.copied=Gekopieerd session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Geselecteerde sessie hernoemen action.Kilo.Session.Delete.text=Verwijderen action.Kilo.Session.Delete.description=Geselecteerde sessie(s) verwijderen action.Kilo.History.ContextMenu.text=Geschiedenisacties +action.Kilo.Session.ContextMenu.text=Sessieacties notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index e27a750e890..96b88efb8be 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Rapporter et problem på GitHub feedback.dialog.discord=Bli med i Discord-fellesskapet vårt feedback.dialog.support=Kundestøtte session.scroll.bottom=Rull til bunnen +session.copy.hover=Kopier +session.copy.copied=Kopiert session.tab.new=Ny økt session.tab.untitled=Uten tittel @@ -147,6 +149,7 @@ action.Kilo.Session.Rename.description=Gi valgt økt nytt navn action.Kilo.Session.Delete.text=Slett action.Kilo.Session.Delete.description=Slett valgte økt(er) action.Kilo.History.ContextMenu.text=Historikkhandlinger +action.Kilo.Session.ContextMenu.text=Økthandlinger notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 4078072e35e..ab1e87d414c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Zgłoś problem na GitHubie feedback.dialog.discord=Dołącz do naszej społeczności Discord feedback.dialog.support=Wsparcie klienta session.scroll.bottom=Przewiń na dół +session.copy.hover=Kopiuj +session.copy.copied=Skopiowano session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu @@ -147,6 +149,7 @@ action.Kilo.Session.Rename.description=Zmień nazwę wybranej sesji action.Kilo.Session.Delete.text=Usuń action.Kilo.Session.Delete.description=Usuń wybraną sesję (wybrane sesje) action.Kilo.History.ContextMenu.text=Akcje historii +action.Kilo.Session.ContextMenu.text=Akcje sesji notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 1d90b2d1237..12ccea438a4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Reportar um problema no GitHub feedback.dialog.discord=Entrar na nossa comunidade Discord feedback.dialog.support=Suporte ao cliente session.scroll.bottom=Rolar para o fim +session.copy.hover=Copiar +session.copy.copied=Copiado session.tab.new=Nova sessão session.tab.untitled=Sessão sem título @@ -147,6 +149,7 @@ action.Kilo.Session.Rename.description=Renomear a sessão selecionada action.Kilo.Session.Delete.text=Excluir action.Kilo.Session.Delete.description=Excluir a(s) sessão(es) selecionada(s) action.Kilo.History.ContextMenu.text=Ações do histórico +action.Kilo.Session.ContextMenu.text=Ações da sessão notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index eda8a4e4b89..18e6b7426b3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Сообщить о проблеме на GitHub feedback.dialog.discord=Присоединиться к нашему Discord feedback.dialog.support=Служба поддержки session.scroll.bottom=Прокрутить вниз +session.copy.hover=Копировать +session.copy.copied=Скопировано session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия @@ -147,6 +149,7 @@ action.Kilo.Session.Rename.description=Переименовать выбранн action.Kilo.Session.Delete.text=Удалить action.Kilo.Session.Delete.description=Удалить выбранную(-ые) сессию(-ии) action.Kilo.History.ContextMenu.text=Действия с историей +action.Kilo.Session.ContextMenu.text=Действия сеанса notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 2f0465d87b6..eb47e0dc50c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -15,6 +15,8 @@ feedback.dialog.github=รายงานปัญหาบน GitHub feedback.dialog.discord=เข้าร่วมชุมชน Discord ของเรา feedback.dialog.support=ฝ่ายสนับสนุนลูกค้า session.scroll.bottom=เลื่อนไปด้านล่าง +session.copy.hover=คัดลอก +session.copy.copied=คัดลอกแล้ว session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ @@ -147,6 +149,7 @@ action.Kilo.Session.Rename.description=เปลี่ยนชื่อเซ action.Kilo.Session.Delete.text=ลบ action.Kilo.Session.Delete.description=ลบเซสชันที่เลือก action.Kilo.History.ContextMenu.text=การดำเนินการประวัติ +action.Kilo.Session.ContextMenu.text=การดำเนินการเซสชัน notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 387408a2338..d6ee2717f8d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -15,6 +15,8 @@ feedback.dialog.github=GitHub'da sorun bildirin feedback.dialog.discord=Discord topluluğumuza katılın feedback.dialog.support=Müşteri Desteği session.scroll.bottom=En alta kaýr +session.copy.hover=Kopyala +session.copy.copied=Kopyalandı session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum @@ -147,6 +149,7 @@ action.Kilo.Session.Rename.description=Seçilen oturumu yeniden adlandır action.Kilo.Session.Delete.text=Sil action.Kilo.Session.Delete.description=Seçilen oturumları sil action.Kilo.History.ContextMenu.text=Geçmiş eylemleri +action.Kilo.Session.ContextMenu.text=Oturum eylemleri notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index d1d775fd7ea..624fc3ba051 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -15,6 +15,8 @@ feedback.dialog.github=Повідомити про проблему на GitHub feedback.dialog.discord=Приєднатися до нашої спільноти Discord feedback.dialog.support=Служба підтримки клієнтів session.scroll.bottom=Прокрутити донизу +session.copy.hover=Копіювати +session.copy.copied=Скопійовано session.tab.new=Нова сесія session.tab.untitled=Сесія без назви @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=Перейменувати вибрану action.Kilo.Session.Delete.text=Видалити action.Kilo.Session.Delete.description=Видалити вибрану(-і) сесію(-ї) action.Kilo.History.ContextMenu.text=Дії з історією +action.Kilo.Session.ContextMenu.text=Дії сеансу notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index a77f8ceed47..d1aa771eedc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -15,6 +15,8 @@ feedback.dialog.github=在 GitHub 上报告问题 feedback.dialog.discord=加入我们的 Discord 社区 feedback.dialog.support=客户支持 session.scroll.bottom=滚动到底部 +session.copy.hover=复制 +session.copy.copied=已复制 session.tab.new=新建会话 session.tab.untitled=无标题会话 @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=重命名所选会话 action.Kilo.Session.Delete.text=删除 action.Kilo.Session.Delete.description=删除所选会话 action.Kilo.History.ContextMenu.text=历史操作 +action.Kilo.Session.ContextMenu.text=会话操作 notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index 4390bd4d66f..b9a1ef9e9b1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -15,6 +15,8 @@ feedback.dialog.github=在 GitHub 上回報問題 feedback.dialog.discord=加入我們的 Discord 社群 feedback.dialog.support=客戶支援 session.scroll.bottom=滾動到底部 +session.copy.hover=複製 +session.copy.copied=已複製 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 @@ -142,6 +144,7 @@ action.Kilo.Session.Rename.description=重命名所選工作階段 action.Kilo.Session.Delete.text=刪除 action.Kilo.Session.Delete.description=刪除所選工作階段 action.Kilo.History.ContextMenu.text=歷史操作 +action.Kilo.Session.ContextMenu.text=工作階段操作 notification.group.kilo=Kilo Code diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt index cfd34206eae..f86d9c65ff7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -414,9 +414,11 @@ class HistorySessionActionsTest : BasePlatformTestCase() { assertTrue(xml.contains("id=\"Kilo.Session.Rename\"")) assertTrue(xml.contains("id=\"Kilo.Session.Delete\"")) assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\"")) + assertTrue(xml.contains("id=\"Kilo.Session.ContextMenu\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Open\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Rename\"")) assertTrue(xml.contains("ref=\"Kilo.Session.Delete\"")) + assertTrue(xml.contains("ref=\"${'$'}Copy\"")) } // ------ Helpers ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 383a7bf6fa9..f266200cf94 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -11,17 +11,20 @@ import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler import ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteProvider import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.test.CopyProviderSink import com.intellij.icons.AllIcons import com.intellij.notification.Notification import com.intellij.notification.Notifications import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext -import com.intellij.openapi.actionSystem.DataSink +import com.intellij.openapi.actionSystem.DataKey import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.editor.actions.PasteAction +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.keymap.KeymapUtil import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -225,6 +228,49 @@ class PromptPanelTest : BasePlatformTestCase() { assertEquals(min, editor.preferredSize.height) } + fun `test prompt editor exposes selection copy provider`() { + val selection = SessionSelection() + val panel = PromptPanel(project = project, selection = selection, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val host = JPanel() + host.add(panel) + host.addNotify() + try { + editor.text = "alpha prompt" + + editor.getEditor(true)!!.selectionModel.setSelection(0, 5) + val sink = TestSink() + (editor as UiDataProvider).uiDataSnapshot(sink) + sink.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + editor.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + selection.dispose() + } + } + + fun `test prompt editor copies full content without selection`() { + val selection = SessionSelection() + val panel = PromptPanel(project = project, selection = selection, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) + val editor = panel.defaultFocusedComponent as EditorTextField + val host = JPanel() + host.add(panel) + host.addNotify() + try { + editor.text = "alpha prompt" + + val sink = TestSink() + (editor as UiDataProvider).uiDataSnapshot(sink) + sink.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha prompt", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + editor.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + selection.dispose() + } + } + fun `test attachment only prompt can send`() { var sent = false val panel = PromptPanel(project, { text, files -> @@ -795,35 +841,13 @@ class PromptPanelTest : BasePlatformTestCase() { } } - private class TestSink : DataSink { + private class TestSink : CopyProviderSink() { var send: Any? = null - override fun set(key: com.intellij.openapi.actionSystem.DataKey, data: T?) { + override fun set(key: DataKey, data: T?) { + super.set(key, data) if (key == PromptDataKeys.SEND) send = data } - - override fun setNull(key: com.intellij.openapi.actionSystem.DataKey) { - } - - override fun lazyNull(key: com.intellij.openapi.actionSystem.DataKey) { - } - - override fun lazyValue( - key: com.intellij.openapi.actionSystem.DataKey, - data: (com.intellij.openapi.actionSystem.DataMap) -> T?, - ) { - } - - override fun uiDataSnapshot(provider: com.intellij.openapi.actionSystem.UiDataProvider) { - provider.uiDataSnapshot(this) - } - - override fun dataSnapshot(provider: com.intellij.openapi.actionSystem.DataSnapshotProvider) { - provider.dataSnapshot(this) - } - - override fun uiDataSnapshot(provider: com.intellij.openapi.actionSystem.DataProvider) { - } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 8a9ae5ccd20..4b3e9c98e10 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -15,6 +15,8 @@ import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView +import ai.kilocode.client.session.views.MessageToolbar +import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.TextView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView @@ -26,12 +28,15 @@ import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout import java.awt.Color import java.awt.Component import java.awt.Container +import java.awt.Point import java.awt.event.MouseEvent import java.awt.image.BufferedImage import javax.swing.JPanel +import javax.swing.SwingUtilities import javax.swing.border.Border /** @@ -172,6 +177,65 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertTrue(mv.part("p1") is TextView) } + fun `test user prompt text part gets copy toolbar`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "hello")) + + val view = panel.findMessage("u1")!!.part("p1") as TextView + val message = panel.findMessage("u1")!! + assertNotNull(find(message)) + assertFalse(view.hasCopyToolbar()) + assertEquals(BorderLayout.LINE_END, message.promptToolbarAlignment()) + assertFalse(message.paintsPromptToolbar()) + + message.setPromptHovered(true) + + assertTrue(message.paintsPromptToolbar()) + + message.setPromptHovered(false) + + assertFalse(message.paintsPromptToolbar()) + } + + fun `test latest non blank assistant text part gets copy toolbar`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + model.updateContent("a1", part("p2", "a1", "text", text = "second")) + + val first = panel.findMessage("a1")!!.part("p1") as TextView + val second = panel.findMessage("a1")!!.part("p2") as TextView + + assertFalse(first.hasCopyToolbar()) + assertTrue(second.hasCopyToolbar()) + } + + fun `test assistant copy toolbar moves back when latest text is removed`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + model.updateContent("a1", part("p2", "a1", "text", text = "second")) + val first = panel.findMessage("a1")!!.part("p1") as TextView + + model.removeContent("a1", "p2") + + assertTrue(first.hasCopyToolbar()) + } + + fun `test assistant copy target spans newest assistant message in turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("a2", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + model.updateContent("a2", part("p2", "a2", "text", text = "second")) + + val first = panel.findMessage("a1")!!.part("p1") as TextView + val second = panel.findMessage("a2")!!.part("p2") as TextView + + assertFalse(first.hasCopyToolbar()) + assertTrue(second.hasCopyToolbar()) + } + fun `test text markdown link uses panel url opener`() { val urls = mutableListOf() val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) }) @@ -205,10 +269,70 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertSame(tv, mv.part("p1")) assertSame(comp, tv.md.component) + assertSame(tv.copyButton(), (mv.part("p1") as TextView).copyButton()) assertSame(first, comp.components.first()) assertEquals("first\n\nsecond more", tv.markdown()) } + fun `test streaming assistant text keeps copy toolbar stable and bounded`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "start")) + val mv = panel.findMessage("a1")!! + val tv = mv.part("p1") as TextView + val comp = tv.md.component + val btn = tv.copyButton() + val count = count(tv) + + repeat(200) { model.appendDelta("a1", "p1", " token$it") } + + assertSame(tv, mv.part("p1")) + assertSame(comp, tv.md.component) + assertSame(btn, tv.copyButton()) + assertEquals(count, count(tv)) + assertTrue(tv.hasCopyToolbar()) + } + + fun `test streaming new assistant text updates copy target without rebuilding previous text`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "first")) + val first = panel.findMessage("a1")!!.part("p1") as TextView + val comp = first.md.component + val button = first.copyButton() + + model.appendDelta("a1", "p2", "second") + + val second = panel.findMessage("a1")!!.part("p2") as TextView + assertSame(first, panel.findMessage("a1")!!.part("p1")) + assertSame(comp, first.md.component) + assertSame(button, first.copyButton()) + assertFalse(first.hasCopyToolbar()) + assertTrue(second.hasCopyToolbar()) + } + + fun `test prompt box paints at wrapped prompt coordinates`() { + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("file1", "u1", "file", text = null)) + model.updateContent("u1", part("p1", "u1", "text", text = "hello")) + val message = panel.findMessage("u1")!! + message.setSize(400, message.preferredSize.height) + message.doLayout() + layout(message) + val box = promptBox(message) + val point = SwingUtilities.convertPoint(box, Point(), message) + assertTrue("prompt box should be below attachment", point.y > 0) + + val image = BufferedImage(message.width, message.height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + message.paint(graphics) + graphics.dispose() + + val line = SessionUiStyle.View.Outline.color().rgb + assertEquals(line, Color(image.getRGB(point.x + box.width / 2, point.y), true).rgb) + assertFalse(line == Color(image.getRGB(point.x + box.width / 2, 0), true).rgb) + } + fun `test created ContentDelta is not double applied`() { model.upsertMessage(msg("a1", "assistant")) @@ -684,4 +808,28 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } + + private fun count(root: Component): Int { + if (root !is Container) return 1 + return 1 + root.components.sumOf(::count) + } + + private fun layout(root: Container) { + root.doLayout() + for (child in root.components) if (child is Container) layout(child) + } + + private fun promptBox(root: MessageView): Component { + return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView } + } + + private fun components(root: Component): List { + val out = mutableListOf() + fun visit(node: Component) { + out.add(node) + if (node is Container) node.components.forEach(::visit) + } + visit(root) + return out + } } 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 index da2e4952e21..a122c5838fd 100644 --- 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 @@ -1,31 +1,45 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase +import ai.kilocode.client.session.ui.selection.SessionCopyTarget +import ai.kilocode.client.session.ui.selection.SessionContextMenu +import ai.kilocode.client.session.ui.selection.SessionHoverCopyOverlay +import ai.kilocode.client.session.ui.selection.SessionTargetResolver import ai.kilocode.client.session.views.tool.ShellToolView import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.test.CopyProviderSink +import ai.kilocode.client.ui.UiStyle 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.util.Disposer import com.intellij.openapi.ide.CopyPasteManager +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane +import java.awt.Component import java.awt.Container +import java.awt.Cursor import java.awt.datatransfer.DataFlavor +import java.awt.event.MouseEvent +import java.awt.Point +import java.awt.image.BufferedImage +import javax.swing.JComponent +import javax.swing.JPanel import javax.swing.text.JTextComponent @Suppress("UnstableApiUsage") class SessionSelectionCopyTest : SessionUiTestBase() { - fun `test session ui exposes copy provider when selection exists`() { + companion object { + private const val RGB_MASK = 0x00ffffff + } + + fun `test transcript view exposes copy provider when selection exists`() { val area = showTool("alpha output") select(area, "alpha") - val provider = copyProvider() + val provider = copyProvider(area) assertNotNull(provider) assertTrue(provider!!.isCopyEnabled(DataContext.EMPTY_CONTEXT)) @@ -35,23 +49,195 @@ class SessionSelectionCopyTest : SessionUiTestBase() { val area = showTool("alpha output") select(area, "alpha") - copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) + copyProvider(area)!!.performCopy(DataContext.EMPTY_CONTEXT) assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } + fun `test copy provider writes full component text without selection`() { + val area = showTool("alpha output") + + copyProvider(area)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha output", 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) + copyProvider(two)!!.performCopy(DataContext.EMPTY_CONTEXT) assertTrue(one.selectedText.isNullOrEmpty()) assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } + fun `test code block child context exposes session copy provider`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + val editor = field.getEditor(true)!! + + editor.selectionModel.setSelection(0, 5) + val provider = copyProvider(field as UiDataProvider) + provider!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test code block child copies full content without selection`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + + copyProvider(field as UiDataProvider)!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertEquals("alpha code", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } + + fun `test session context menu resolves deepest component`() { + val root = JPanel(null) + val mid = JPanel(null) + val child = JPanel(null) + root.setBounds(0, 0, 100, 100) + mid.setBounds(10, 10, 80, 80) + child.setBounds(5, 5, 20, 20) + root.add(mid) + mid.add(child) + + assertSame(child, SessionContextMenu.target(root, root, Point(20, 20))) + } + + fun `test hover copy resolver skips overlay and resolves underlying target`() { + val root = JPanel(null) + val target = TargetPanel("alpha") + val overlay = JPanel(null) + root.setBounds(0, 0, 100, 100) + target.setBounds(10, 10, 80, 80) + overlay.setBounds(15, 15, 20, 20) + root.add(target) + root.add(overlay) + + val item = SessionTargetResolver.copy(root, root, Point(20, 20), overlay) + + assertSame(target, item) + } + + fun `test hover copy resolver prefers outer target for stable anchoring`() { + val root = JPanel(null) + val target = TargetPanel("alpha") + val child = TargetPanel("bravo") + root.setBounds(0, 0, 100, 100) + target.setBounds(10, 10, 80, 80) + child.setBounds(5, 5, 20, 20) + root.add(target) + target.add(child) + + val item = SessionTargetResolver.copy(root, root, Point(20, 20)) + + assertSame(target, item) + } + + fun `test code block hover copy target copies full content despite selection`() { + showText("```text\nalpha code\n```") + val field = textEditors(ui).first { it.text.contains("alpha code") } + field.setSize(200, 80) + field.getEditor(true)!!.selectionModel.setSelection(0, 5) + + val target = SessionTargetResolver.copy(field, field, Point(1, 1)) + + assertNotNull(target) + assertEquals("alpha code", target!!.copyText()) + } + + fun `test plain assistant text is not hover copy eligible`() { + showText("plain alpha") + val comp = textComponent("plain alpha") + comp.setSize(200, 80) + + val target = SessionTargetResolver.copy(comp, comp, Point(1, 1)) + + assertNull(target) + } + + fun `test session ui registers hover copy overlay in overlay layer`() { + val root = find(ui) + val overlay = find(ui) + + assertSame(root.overlay, overlay.parent) + assertFalse(root.blocker.components.contains(overlay)) + assertFalse(overlay.isVisible) + assertEquals(Cursor.HAND_CURSOR, overlay.components.single().cursor.type) + overlay.isVisible = true + overlay.clear() + assertFalse(overlay.isVisible) + } + + fun `test hover copy overlay ignores mouse events after disposal`() { + val root = ShowingPanel() + val parent = Disposer.newDisposable("overlay-test") + val target = TargetPanel("alpha") + val overlay = SessionHoverCopyOverlay(root, parent) + root.setBounds(0, 0, 100, 100) + target.setBounds(10, 10, 80, 80) + root.add(target) + root.add(overlay) + + Disposer.dispose(parent) + target.dispatchEvent(MouseEvent(target, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + + assertFalse(overlay.isVisible) + } + + fun `test session context menu can reinstall after parent disposal`() { + val root = JPanel(null) + val one = Disposer.newDisposable("context-one") + val two = Disposer.newDisposable("context-two") + + SessionContextMenu.install(root, one) + SessionContextMenu.install(root, one) + Disposer.dispose(one) + SessionContextMenu.install(root, two) + Disposer.dispose(two) + } + + fun `test hover copy button paints opaque background before and during pointer hover`() { + val overlay = find(ui) + val btn = overlay.components.single() + val size = btn.preferredSize + btn.setBounds(0, 0, size.width, size.height) + + assertEquals(rgb(UiStyle.Colors.bg()), argb(btn, 2, size.height / 2) and RGB_MASK) + + btn.dispatchEvent(MouseEvent(btn, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false)) + + assertEquals(255, argb(btn, 2, size.height / 2) ushr 24) + } + + private fun argb(comp: Component, x: Int, y: Int): Int { + val size = comp.size + val image = BufferedImage(size.width, size.height, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + + try { + comp.paint(g) + } finally { + g.dispose() + } + return image.getRGB(x, y) + } + + private fun rgb(color: java.awt.Color): Int = color.rgb and RGB_MASK + + fun `test hover overlay keeps current target while pointer remains inside anchor`() { + val overlay = find(ui) + val target = TargetPanel("alpha") + target.setBounds(10, 10, 80, 80) + + assertTrue(overlay.contains(target, target, Point(79, 1))) + assertFalse(overlay.contains(target, target, Point(80, 1))) + } + private fun select(area: JTextComponent, text: String) { val start = area.text.indexOf(text) assertTrue(start >= 0) @@ -79,6 +265,13 @@ class SessionSelectionCopyTest : SessionUiTestBase() { return textComponent(text) } + private fun showText(text: String) { + if (controller().id == null) showMessages() + emit(ChatEventDto.MessageUpdated("ses_test", message("msg_text"))) + emit(ChatEventDto.PartUpdated("ses_test", part("part_text", "msg_text", "text", text))) + layout() + } + private fun toolViews(root: Container): List { val out = mutableListOf() if (root is ShellToolView || root is ToolView) out.add(root) @@ -94,12 +287,46 @@ class SessionSelectionCopyTest : SessionUiTestBase() { else -> false } - private fun copyProvider(): CopyProvider? { - val sink = CopySink() - (ui as UiDataProvider).uiDataSnapshot(sink) + private fun copyProvider(provider: UiDataProvider): CopyProvider? { + val sink = CopyProviderSink() + provider.uiDataSnapshot(sink) return sink.copy } + private fun copyProvider(component: Component): CopyProvider? { + (component as? UiDataProvider)?.let(::copyProvider)?.let { return it } + ancestors(component).filterIsInstance().firstNotNullOfOrNull(::copyProvider)?.let { return it } + val point = Point((component.width / 2).coerceAtLeast(0), (component.height / 2).coerceAtLeast(0)) + val target = SessionContextMenu.target(ui as JComponent, component, point) ?: component + ancestors(target).filterIsInstance().firstNotNullOfOrNull(::copyProvider)?.let { return it } + return providers(ui).firstNotNullOfOrNull(::copyProvider) + } + + private fun providers(root: Component): Sequence = sequence { + if (root is UiDataProvider) yield(root) + if (root is Container) { + for (child in root.components) yieldAll(providers(child)) + } + } + + private fun ancestors(component: Component): Sequence = sequence { + var comp: Component? = component + while (comp != null) { + yield(comp) + comp = comp.parent + } + } + + private fun textEditors(root: Container): List { + val out = mutableListOf() + if (root is EditorTextField) out.add(root) + for (child in root.components) { + if (child is JBScrollPane) (child.viewport.view as? EditorTextField)?.let(out::add) + if (child is Container) out.addAll(textEditors(child)) + } + return out.distinct() + } + private fun textComponent(needle: String): JTextComponent = textComponents(ui) .first { it.text.contains(needle) } @@ -112,21 +339,13 @@ class SessionSelectionCopyTest : SessionUiTestBase() { return out } - private class CopySink : DataSink { - var copy: CopyProvider? = null + private class TargetPanel(private val value: String) : JPanel(), SessionCopyTarget { + override val copyAnchor: JComponent get() = this - 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 copyText() = value + } - override fun uiDataSnapshot(provider: UiDataProvider) = provider.uiDataSnapshot(this) - override fun dataSnapshot(provider: DataSnapshotProvider) = provider.dataSnapshot(this) - override fun uiDataSnapshot(provider: DataProvider) {} + private class ShowingPanel : JPanel(null) { + override fun isShowing() = true } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 5bb1692ed34..af6a172429f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -285,7 +285,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { assertNull(msg.part("p2")) assertEquals(listOf("p1", "f1"), msg.partIds()) - assertEquals(1, msg.components.filterIsInstance().size) + assertTrue(msg.part("p1") is TextView) assertEquals(1, msg.components.filterIsInstance().size) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 8d0584de6d7..8f62d8c0858 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -3,8 +3,12 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.util.ui.JBUI import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.BorderLayout +import java.awt.datatransfer.DataFlavor +import java.awt.event.MouseEvent import javax.swing.JComponent import javax.swing.RepaintManager @@ -99,6 +103,82 @@ class TextViewTest : BasePlatformTestCase() { assertNotNull(view.md.component) } + fun `test copy toolbar is retained below markdown component`() { + val view = TextView(Text("p1").also { it.content.append(" hello ") }) + + view.setCopyToolbar(true) + + val layout = view.layout as BorderLayout + assertSame(view.md.component, layout.getLayoutComponent(BorderLayout.CENTER)) + val bar = layout.getLayoutComponent(BorderLayout.SOUTH) as MessageToolbar + val buttons = bar.layout as BorderLayout + assertSame(view.copyButton(), buttons.getLayoutComponent(BorderLayout.LINE_START)) + assertTrue(view.hasCopyToolbar()) + } + + fun `test assistant copy button copies current trimmed markdown`() { + val view = TextView(Text("p1").also { it.content.append(" hello ") }) + view.setCopyToolbar(true) + + view.copyButton().doClick() + + assertEquals("hello", clipboard()) + } + + fun `test copy confirmation hides when mouse exits button`() { + val view = TextView(Text("p1").also { it.content.append("hello") }) + view.setCopyToolbar(true) + + view.copyButton().doClick() + view.copyButton().dispatchEvent(MouseEvent( + view.copyButton(), + MouseEvent.MOUSE_EXITED, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + + assertEquals("hello", clipboard()) + } + + fun `test copy toolbar reflects update and delta without replacing components`() { + val view = TextView(Text("p1").also { it.content.append(" first ") }) + view.setCopyToolbar(true) + val comp = view.md.component + val bar = (view.layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH) + + view.update(Text("p1").also { it.content.append(" second ") }) + view.appendDelta(" third ") + view.copyButton().doClick() + + assertSame(comp, view.md.component) + assertSame(bar, (view.layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) + assertEquals("second third", clipboard()) + } + + fun `test text view can copy untrimmed markdown`() { + val view = PromptView(Text("p1").also { it.content.append(" hello ") }) + view.setCopyToolbar(true, trim = false) + + view.copyButton().doClick() + + assertEquals(" hello ", clipboard()) + } + + fun `test blank copy toolbar is hidden until content appears`() { + val view = TextView(Text("p1")) + view.setCopyToolbar(true) + + assertFalse(view.hasCopyToolbar()) + + view.appendDelta("hello") + + assertTrue(view.hasCopyToolbar()) + } + fun `test markdown uses ui family with editor size`() { val style = SessionEditorStyle.current() val view = TextView(Text("p1")) @@ -182,4 +262,8 @@ class TextViewTest : BasePlatformTestCase() { super.addInvalidComponent(invalidComponent) } } + + private fun clipboard() = CopyPasteManager.getInstance() + .contents + ?.getTransferData(DataFlavor.stringFlavor) as String } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/test/CopyProviderSink.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/test/CopyProviderSink.kt new file mode 100644 index 00000000000..578bf0226d1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/test/CopyProviderSink.kt @@ -0,0 +1,29 @@ +package ai.kilocode.client.test + +import com.intellij.ide.CopyProvider +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 + +@Suppress("UnstableApiUsage") +open class CopyProviderSink : 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/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 6a6a4ce3ad5..7a0a46884c5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -1,9 +1,13 @@ 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.client.test.CopyProviderSink import com.intellij.execution.process.ProcessOutputTypes import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.UiDataProvider import com.intellij.openapi.editor.DefaultLanguageHighlighterColors import com.intellij.openapi.editor.HighlighterColors import com.intellij.openapi.editor.colors.CodeInsightColors @@ -15,6 +19,7 @@ import com.intellij.openapi.fileTypes.FileTypeRegistry import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.editor.markup.TextAttributes +import com.intellij.openapi.ide.CopyPasteManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.EditorTextField @@ -28,6 +33,7 @@ import java.awt.Font import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import java.awt.datatransfer.DataFlavor @Suppress("UnstableApiUsage") class MdViewHybridTest : BasePlatformTestCase() { @@ -793,6 +799,33 @@ class MdViewHybridTest : BasePlatformTestCase() { assertEquals("https://example.com", received.single().href) } + fun `test markdown root and code child expose selection copy provider`() { + Disposer.dispose(view) + disposed = true + val selection = SessionSelection() + val local = MdViewFactory.hybrid(selection = selection) + try { + local.set("```text\nalpha code\n```") + val field = (local.component as JPanel).components + .filterIsInstance() + .mapNotNull { it.viewport.view as? EditorTextField } + .single() + field.getEditor(true)!!.selectionModel.setSelection(0, 5) + + val root = CopyProviderSink() + (local.component as UiDataProvider).uiDataSnapshot(root) + val child = CopyProviderSink() + (field as UiDataProvider).uiDataSnapshot(child) + child.copy!!.performCopy(DataContext.EMPTY_CONTEXT) + + assertNotNull(root.copy) + assertEquals("alpha", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) + } finally { + Disposer.dispose(local) + selection.dispose() + } + } + private fun scrolls(): List = (view.component as JPanel).components.filterIsInstance() private fun htmls(): List = (view.component as JPanel).components.filterIsInstance()