fix(jetbrains): open session markdown links

This commit is contained in:
kirillk
2026-05-27 14:59:01 -04:00
parent 98de19540c
commit 5bf7bd8bbb
13 changed files with 90 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support opening links in JetBrains session markdown transcripts.
@@ -36,6 +36,7 @@ import ai.kilocode.client.ui.layout.Stack
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.ui.LafManagerListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
@@ -223,7 +224,7 @@ class SessionUi(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile)
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -433,6 +434,10 @@ class SessionUi(
}
}
private fun openUrl(url: String) {
BrowserUtil.browse(url)
}
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
load.setState(state)
@@ -47,6 +47,7 @@ class SessionMessageListPanel(
private val permission: PermissionView? = null,
private val login: LoginRequiredView? = null,
private val openFile: (String) -> Unit,
private val openUrl: (String) -> Unit = {},
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -174,7 +175,7 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, openFile, style)
val tv = TurnView(turn.id, openFile, style, openUrl)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -225,7 +226,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, openFile, style)
val tv = TurnView(turn.id, openFile, style, openUrl)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -31,6 +31,7 @@ class MessageView(
val msg: Message,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
) : ai.kilocode.client.session.ui.SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
), SessionEditorStyleTarget, SessionView {
@@ -165,9 +166,9 @@ class MessageView(
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile)
ViewFactory.createUser(content, openFile, openUrl)
} else {
ViewFactory.create(content, openFile)
ViewFactory.create(content, openFile, openUrl)
}
/** Append a streaming delta to the renderer for [contentId]. */
@@ -23,7 +23,11 @@ import javax.swing.ScrollPaneConstants
import javax.swing.Scrollable
/** Renders reasoning as a secondary collapsible block. */
class ReasoningView(reasoning: Reasoning, private val parts: ReasoningParts = reasoningParts()) :
class ReasoningView(
reasoning: Reasoning,
openUrl: (String) -> Unit = {},
private val parts: ReasoningParts = reasoningParts(),
) :
SecondarySessionPartView(parts.header, parts.scroll) {
override val contentId: String = reasoning.id
@@ -37,6 +41,7 @@ class ReasoningView(reasoning: Reasoning, private val parts: ReasoningParts = re
bindHeader(parts.title, parts.icon)
applyStyle(style)
md.opaque = false
md.addLinkListener { openUrl(it.href) }
md.set(source)
parts.panel.add(md.component, BorderLayout.CENTER)
sync()
@@ -12,7 +12,11 @@ import java.awt.BorderLayout
*
* Supports both full-replacement ([update]) and streaming append ([appendDelta]).
*/
class TextView(text: Text, transparent: Boolean = false) : PartView() {
class TextView(
text: Text,
transparent: Boolean = false,
openUrl: (String) -> Unit = {},
) : PartView() {
override val contentId: String = text.id
@@ -22,6 +26,7 @@ class TextView(text: Text, transparent: Boolean = false) : PartView() {
layout = BorderLayout()
isOpaque = false
md.opaque = !transparent
md.addLinkListener { openUrl(it.href) }
applyStyle(SessionEditorStyle.current())
add(md.component, BorderLayout.CENTER)
if (text.content.isNotEmpty()) md.set(text.content.toString())
@@ -189,10 +189,14 @@ class ReadToolView(
internal fun hasToggle() = arrow.isVisible
internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES
internal fun bodyFont() = parts.text.font
internal fun linkVisible() = parts.link.isVisible
internal fun linkText() = parts.label
internal fun linkMarkup() = parts.link.text ?: ""
internal fun linkForeground() = parts.link.foreground
internal fun linkFont() = parts.link.font
internal fun subtitleForeground() = parts.sub.foreground
internal fun subtitleFont() = parts.sub.font
internal fun linkHref() = parts.href
internal fun openLink() = parts.openLink()
@@ -200,8 +204,8 @@ class ReadToolView(
this.style = style
var changed = false
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.smallEditorFont) || changed
changed = setFont(parts.link, style.smallEditorFont) || changed
changed = setFont(parts.sub, style.transcriptFont) || changed
changed = setFont(parts.link, style.transcriptFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = setFont(parts.text, style.transcriptFont) || changed
if (changed) refresh()
@@ -216,6 +220,8 @@ class ReadToolView(
changed = setText(parts.title, title(item)) || changed
changed = syncSubtitle() || changed
changed = setForeground(parts.title, titleColor(item)) || changed
changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed
changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
changed = setForeground(parts.text, bodyColor()) || changed
@@ -297,7 +303,7 @@ private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolPar
val link = JBLabel().apply {
isVisible = false
isFocusable = false
foreground = UiStyle.Colors.weak()
foreground = UiStyle.Colors.fg()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
setRequestFocusEnabled(false)
addMouseListener(object : MouseAdapter() {
@@ -20,6 +20,7 @@ class TurnView(
val id: String,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget {
constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current())
@@ -32,7 +33,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)
val view = MessageView(msg, openFile, style, openUrl)
messages[msg.info.id] = view
add(view)
revalidate()
@@ -21,9 +21,13 @@ 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 = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
fun create(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit = {},
): PartView = when (content) {
is Text -> TextView(content, openUrl = openUrl)
is Reasoning -> ReasoningView(content, openUrl = openUrl)
is Tool -> when {
TodoWriteView.canRender(content) -> TodoWriteView(content)
PlanExitView.canRender(content) -> PlanExitView(content, openFile)
@@ -36,9 +40,13 @@ object ViewFactory {
is Generic -> GenericView(content)
}
fun createUser(content: Content, openFile: (String) -> Unit): PartView = when (content) {
is Text -> TextView(content, transparent = true)
else -> create(content, openFile)
fun createUser(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit = {},
): PartView = when (content) {
is Text -> TextView(content, transparent = true, openUrl = openUrl)
else -> create(content, openFile, openUrl)
}
/**
@@ -165,6 +165,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(mv.part("p1") is TextView)
}
fun `test text markdown link uses panel url opener`() {
val urls = mutableListOf<String>()
val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) })
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "[docs](https://kilocode.ai/docs)"))
val view = item.findMessage("a1")!!.part("p1") as TextView
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
fun `test ContentDelta appends text to TextView`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "hello "))
@@ -49,7 +49,8 @@ class ReadToolViewTest : BasePlatformTestCase() {
assertEquals("SessionUiLayoutTest.kt", view.linkText())
assertEquals(path, view.linkHref())
assertTrue(view.linkMarkup().contains("<u>SessionUiLayoutTest.kt</u>"))
assertEquals(UiStyle.Colors.weak().rgb, view.linkForeground().rgb)
assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb)
assertEquals(view.linkFont(), view.bodyFont())
assertTrue(view.labelText().contains("SessionUiLayoutTest.kt"))
view.openLink()
@@ -71,6 +72,8 @@ class ReadToolViewTest : BasePlatformTestCase() {
assertFalse(view.linkVisible())
assertNull(view.linkHref())
assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb)
assertEquals(view.subtitleFont(), view.bodyFont())
assertTrue(view.labelText().contains(path))
}
@@ -163,6 +163,17 @@ class ReasoningViewTest : BasePlatformTestCase() {
assertTrue(view.preferredSize.height > 0)
}
fun `test link opens url callback`() {
val urls = mutableListOf<String>()
val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = {
urls.add(it)
})
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) {
assertTrue(sheet.contains(style.editorFamily))
assertTrue(sheet.contains("${style.editorSize}pt"))
@@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() {
view.appendDelta("**")
assertTrue(view.md.html().contains("<strong>"))
}
fun `test link opens url callback`() {
val urls = mutableListOf<String>()
val view = TextView(Text("p1"), openUrl = { urls.add(it) })
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
}