diff --git a/.changeset/jetbrains-session-links.md b/.changeset/jetbrains-session-links.md new file mode 100644 index 00000000000..63de324a815 --- /dev/null +++ b/.changeset/jetbrains-session-links.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Support opening links in JetBrains session markdown transcripts. 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 e4aed46e47d..9c2a3d69285 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 @@ -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) 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 cad61f48fef..f21a2b669bc 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 @@ -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 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 52081071d76..efe87c02880 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 @@ -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]. */ diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index fe7ed35b31a..0ef0c7c2f6b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -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() 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 af19d386ef6..ff00cdbe3b5 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 @@ -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()) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index e3dac956d48..b5ad3638aa3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -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() { 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 2bea4ff421e..1b1d7283ead 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 @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index 62f72c5f095..e3ce38fb78a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -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) } /** 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 659d85d3cef..6d5a067309d 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 @@ -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() + 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 ")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index 0a270461248..a1d27fab1bb 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -49,7 +49,8 @@ class ReadToolViewTest : BasePlatformTestCase() { assertEquals("SessionUiLayoutTest.kt", view.linkText()) assertEquals(path, view.linkHref()) assertTrue(view.linkMarkup().contains("SessionUiLayoutTest.kt")) - 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)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index 8a3692a1982..a343491ba80 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -163,6 +163,17 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.preferredSize.height > 0) } + fun `test link opens url callback`() { + val urls = mutableListOf() + 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")) 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 a457b45d9e2..e1684b69347 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 @@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() { view.appendDelta("**") assertTrue(view.md.html().contains("")) } + + fun `test link opens url callback`() { + val urls = mutableListOf() + val view = TextView(Text("p1"), openUrl = { urls.add(it) }) + + view.md.simulateLink("https://kilocode.ai/docs") + + assertEquals(listOf("https://kilocode.ai/docs"), urls) + } }