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