Skip to content

Commit 2a3975e

Browse files
committed
fix(jetbrains): support copying session text
1 parent bee5475 commit 2a3975e

22 files changed

Lines changed: 658 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"kilo-code": patch
3+
---
4+
5+
Support coherent selection and copy behavior across JetBrains session transcript fragments.

packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import ai.kilocode.client.session.ui.account.SessionAccountOverlay
2222
import ai.kilocode.client.session.ui.SessionRootPanel
2323
import ai.kilocode.client.session.ui.SessionMessageListPanel
2424
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
25+
import ai.kilocode.client.session.ui.selection.SessionSelection
2526
import ai.kilocode.client.session.ui.style.SessionEditorStyle
2627
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
2728
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
@@ -38,6 +39,11 @@ import ai.kilocode.log.ChatLogSummary
3839
import com.intellij.util.ui.JBUI
3940
import ai.kilocode.log.KiloLog
4041
import com.intellij.ide.BrowserUtil
42+
import com.intellij.ide.TextCopyProvider
43+
import com.intellij.openapi.actionSystem.ActionUpdateThread
44+
import com.intellij.openapi.actionSystem.DataSink
45+
import com.intellij.openapi.actionSystem.PlatformDataKeys
46+
import com.intellij.openapi.actionSystem.UiDataProvider
4147
import com.intellij.ide.ui.LafManagerListener
4248
import com.intellij.openapi.Disposable
4349
import com.intellij.openapi.application.ApplicationManager
@@ -79,7 +85,7 @@ class SessionUi(
7985
private val manager: SessionManager? = null,
8086
private val workspaces: KiloWorkspaceService = service(),
8187
private val migration: MigrationUiController = service<KiloMigrationService>(),
82-
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget {
88+
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget, UiDataProvider {
8389

8490
companion object {
8591
private val LOG = KiloLog.create(SessionUi::class.java)
@@ -143,12 +149,22 @@ class SessionUi(
143149
private var empty: EmptySessionPanel? = null
144150
private var modalFocus: (() -> JComponent)? = null
145151
private var style = SessionEditorStyle.current()
152+
private val selection = SessionSelection()
153+
private val copy = object : TextCopyProvider() {
154+
override fun getActionUpdateThread() = ActionUpdateThread.EDT
155+
156+
override fun getTextLinesToCopy(): Collection<String>? {
157+
val text = selection.selectedText()?.takeIf { it.isNotEmpty() } ?: return null
158+
return listOf(text)
159+
}
160+
}
146161
private var editorTheme = style.editorScheme
147162
private var colorTheme = UIManager.getLookAndFeel()
148163
private var disposed = false
149164

150165
init {
151166
buildUi()
167+
Disposer.register(this, selection)
152168
scroll.show(body(controller.model.state))
153169
bindUi()
154170
bindStyle()
@@ -178,6 +194,10 @@ class SessionUi(
178194

179195
internal fun currentStyle() = style
180196

197+
override fun uiDataSnapshot(sink: DataSink) {
198+
sink[PlatformDataKeys.COPY_PROVIDER] = copy
199+
}
200+
181201
@RequiresEdt
182202
internal fun canDisposeInactive(): Boolean = controller.model.state is SessionState.Idle
183203

@@ -259,12 +279,18 @@ class SessionUi(
259279
reject = { id -> controller.rejectQuestion(id) },
260280
follow = { scroll.following() },
261281
scroll = { scroll.followBottom(it) },
282+
selection = selection,
262283
)
263284
permission = PermissionView(
264285
reply = { id, dto -> controller.replyPermission(id, dto) },
286+
selection = selection,
287+
)
288+
login = LoginRequiredView(
289+
openProfile = { controller.openProfile() },
290+
dismiss = { controller.dismissLoginRequired() },
291+
selection = selection,
265292
)
266-
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
267-
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl)
293+
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl, selection)
268294
header = SessionHeaderPanel(controller, this)
269295

270296
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -524,6 +550,7 @@ class SessionUi(
524550

525551
override fun applyStyle(style: SessionEditorStyle) {
526552
this.style = style
553+
selection.applyStyle(style)
527554
editorTheme = style.editorScheme
528555
colorTheme = UIManager.getLookAndFeel()
529556
background = style.editorBackground

packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ai.kilocode.client.session.model.SessionModelEvent
55
import ai.kilocode.client.session.model.SessionState
66
import ai.kilocode.client.session.model.ToolCallRef
77
import ai.kilocode.client.session.ui.style.SessionEditorStyle
8+
import ai.kilocode.client.session.ui.selection.SessionSelection
89
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
910
import ai.kilocode.client.session.ui.style.SessionUiStyle
1011
import ai.kilocode.client.session.views.LoginRequiredView
@@ -49,6 +50,7 @@ class SessionMessageListPanel(
4950
private val login: LoginRequiredView? = null,
5051
private val openFile: (String) -> Unit,
5152
private val openUrl: (String) -> Unit = {},
53+
private val selection: SessionSelection? = null,
5254
) : SessionLayoutPanel(
5355
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
5456
JBUI.insets(
@@ -177,7 +179,7 @@ class SessionMessageListPanel(
177179
// ------ private event handlers ------
178180

179181
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
180-
val tv = TurnView(turn.id, openFile, style, openUrl)
182+
val tv = TurnView(turn.id, openFile, style, openUrl, selection)
181183
turnViews[turn.id] = tv
182184
for (msgId in turn.messageIds) {
183185
val msg = model.message(msgId) ?: continue
@@ -233,7 +235,7 @@ class SessionMessageListPanel(
233235
removeAll()
234236

235237
for (turn in model.turns()) {
236-
val tv = TurnView(turn.id, openFile, style, openUrl)
238+
val tv = TurnView(turn.id, openFile, style, openUrl, selection)
237239
turnViews[turn.id] = tv
238240
for (msgId in turn.messageIds) {
239241
val msg = model.message(msgId) ?: continue
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
package ai.kilocode.client.session.ui.selection
2+
3+
import ai.kilocode.client.session.ui.style.SessionEditorStyle
4+
import com.intellij.openapi.Disposable
5+
import com.intellij.openapi.editor.colors.EditorColors
6+
import com.intellij.openapi.editor.event.SelectionEvent
7+
import com.intellij.openapi.editor.event.SelectionListener
8+
import com.intellij.openapi.editor.ex.EditorEx
9+
import com.intellij.openapi.util.Disposer
10+
import com.intellij.ui.EditorTextField
11+
import com.intellij.util.concurrency.annotations.RequiresEdt
12+
import java.awt.Color
13+
import java.awt.event.MouseAdapter
14+
import java.awt.event.MouseEvent
15+
import javax.swing.event.CaretEvent
16+
import javax.swing.event.CaretListener
17+
import javax.swing.text.JTextComponent
18+
19+
class SessionSelection : Disposable {
20+
private val items = linkedSetOf<Item>()
21+
private var active: Item? = null
22+
private var style: SessionEditorStyle? = null
23+
private var clearing = false
24+
private var disposed = false
25+
26+
@RequiresEdt
27+
fun selectedText(): String? {
28+
active?.selectedText()?.takeIf { it.isNotEmpty() }?.let { return it }
29+
val item = items.toList().asReversed().firstOrNull { !it.selectedText().isNullOrEmpty() }
30+
if (item == null) {
31+
active = null
32+
return null
33+
}
34+
active = item
35+
clearExcept(item)
36+
return item.selectedText()?.takeIf { it.isNotEmpty() }
37+
}
38+
39+
@RequiresEdt
40+
fun register(component: JTextComponent, parent: Disposable? = null): Disposable {
41+
val item = TextItem(component)
42+
add(item, parent)
43+
return item
44+
}
45+
46+
@RequiresEdt
47+
fun register(field: EditorTextField, parent: Disposable? = null): Disposable {
48+
val item = FieldItem(field)
49+
add(item, parent)
50+
return item
51+
}
52+
53+
@RequiresEdt
54+
fun register(editor: EditorEx, parent: Disposable? = null): Disposable {
55+
val item = EditorItem(editor)
56+
add(item, parent)
57+
return item
58+
}
59+
60+
@RequiresEdt
61+
private fun clearExcept(item: Item) {
62+
if (clearing) return
63+
clearing = true
64+
try {
65+
for (entry in items) {
66+
if (entry !== item) entry.clearSelection()
67+
}
68+
} finally {
69+
clearing = false
70+
}
71+
}
72+
73+
@RequiresEdt
74+
fun clear() {
75+
if (clearing) return
76+
clearing = true
77+
try {
78+
for (entry in items) entry.clearSelection()
79+
active = null
80+
} finally {
81+
clearing = false
82+
}
83+
}
84+
85+
@RequiresEdt
86+
fun applyStyle(style: SessionEditorStyle) {
87+
this.style = style
88+
for (item in items) item.applyStyle(style)
89+
}
90+
91+
@RequiresEdt
92+
override fun dispose() {
93+
disposed = true
94+
clear()
95+
val copy = items.toList()
96+
for (item in copy) item.dispose()
97+
items.clear()
98+
active = null
99+
}
100+
101+
@RequiresEdt
102+
private fun add(item: Item, parent: Disposable?) {
103+
if (disposed) return
104+
items.add(item)
105+
style?.let(item::applyStyle)
106+
parent?.let { Disposer.register(it, item) }
107+
}
108+
109+
@RequiresEdt
110+
private fun changed(item: Item) {
111+
if (clearing || item.disposed) return
112+
if (!items.contains(item)) return
113+
if (!item.selectedText().isNullOrEmpty()) {
114+
active = item
115+
clearExcept(item)
116+
return
117+
}
118+
if (active === item) active = null
119+
}
120+
121+
@RequiresEdt
122+
private fun started(item: Item) {
123+
if (clearing || item.disposed) return
124+
if (!items.contains(item)) return
125+
active = item
126+
clearExcept(item)
127+
}
128+
129+
private interface Item : Disposable {
130+
val disposed: Boolean
131+
fun selectedText(): String?
132+
fun clearSelection()
133+
fun applyStyle(style: SessionEditorStyle)
134+
}
135+
136+
private inner class TextItem(private val component: JTextComponent) : Item, CaretListener {
137+
private val mouse = object : MouseAdapter() {
138+
override fun mousePressed(e: MouseEvent) = started(this@TextItem)
139+
}
140+
141+
override var disposed = false
142+
private set
143+
144+
init {
145+
component.caret.isSelectionVisible = true
146+
component.caret.isVisible = false
147+
component.isFocusable = true
148+
component.isRequestFocusEnabled = true
149+
component.addCaretListener(this)
150+
component.addMouseListener(mouse)
151+
}
152+
153+
override fun caretUpdate(e: CaretEvent) = changed(this)
154+
155+
override fun selectedText(): String? = component.selectedText
156+
157+
override fun clearSelection() {
158+
val pos = component.selectionStart.coerceIn(0, component.document.length)
159+
component.select(pos, pos)
160+
}
161+
162+
override fun applyStyle(style: SessionEditorStyle) {
163+
selectionColors(style, component.selectionColor, component.selectedTextColor).let {
164+
component.selectionColor = it.first
165+
component.selectedTextColor = it.second
166+
}
167+
}
168+
169+
override fun dispose() {
170+
if (disposed) return
171+
disposed = true
172+
component.removeCaretListener(this)
173+
component.removeMouseListener(mouse)
174+
if (active === this) active = null
175+
items.remove(this)
176+
}
177+
}
178+
179+
private inner class FieldItem(private val field: EditorTextField) : Item, SelectionListener {
180+
private var editor: EditorEx? = null
181+
private var reg: Disposable? = null
182+
override var disposed = false
183+
private set
184+
185+
init {
186+
field.getEditor(false)?.let(::bind)
187+
field.addSettingsProvider { ed -> bind(ed) }
188+
}
189+
190+
override fun selectedText(): String? = editor?.selectionModel?.selectedText
191+
192+
override fun clearSelection() {
193+
editor?.selectionModel?.removeSelection()
194+
}
195+
196+
override fun applyStyle(style: SessionEditorStyle) {
197+
editor?.let(style::applyToEditor)
198+
}
199+
200+
override fun selectionChanged(e: SelectionEvent) = changed(this)
201+
202+
override fun dispose() {
203+
if (disposed) return
204+
disposed = true
205+
reg?.let(Disposer::dispose)
206+
reg = null
207+
editor = null
208+
if (active === this) active = null
209+
items.remove(this)
210+
}
211+
212+
private fun bind(editor: EditorEx) {
213+
if (disposed || this.editor != null) return
214+
this.editor = editor
215+
val disposable = Disposer.newDisposable("Session selection editor")
216+
reg = disposable
217+
editor.selectionModel.addSelectionListener(this, disposable)
218+
style?.let(::applyStyle)
219+
}
220+
}
221+
222+
private inner class EditorItem(private val editor: EditorEx) : Item, SelectionListener {
223+
override var disposed = false
224+
private set
225+
226+
init {
227+
editor.selectionModel.addSelectionListener(this, this)
228+
}
229+
230+
override fun selectionChanged(e: SelectionEvent) = changed(this)
231+
232+
override fun selectedText(): String? = editor.selectionModel.selectedText
233+
234+
override fun clearSelection() {
235+
editor.selectionModel.removeSelection()
236+
}
237+
238+
override fun applyStyle(style: SessionEditorStyle) {
239+
style.applyToEditor(editor)
240+
}
241+
242+
override fun dispose() {
243+
if (disposed) return
244+
disposed = true
245+
if (active === this) active = null
246+
items.remove(this)
247+
}
248+
}
249+
250+
private fun selectionColors(style: SessionEditorStyle, bg: Color?, fg: Color?): Pair<Color, Color> {
251+
val scheme = style.editorScheme
252+
return (scheme.getColor(EditorColors.SELECTION_BACKGROUND_COLOR) ?: bg ?: style.editorBackground) to
253+
(scheme.getColor(EditorColors.SELECTION_FOREGROUND_COLOR) ?: fg ?: style.editorForeground)
254+
}
255+
}

0 commit comments

Comments
 (0)