From 627bcc0f8732f736ef933c7c9c2a5c897c1decd4 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:45:03 +1000
Subject: [PATCH] fix(app): make links in shell and Code Mode output clickable
(#57303)
---
.../src/components/link-segments.test.ts | 28 +++++++++++++++++++
.../src/components/link-segments.ts | 20 +++++++++++++
.../session-ui/src/components/linked-text.tsx | 21 ++++++++++++++
.../src/components/message-part.css | 12 ++++++++
.../src/message/message-content.tsx | 25 ++++++-----------
.../session-ui/src/tools/tool-renderer.tsx | 15 ++++++++--
6 files changed, 101 insertions(+), 20 deletions(-)
create mode 100644 packages/session-ui/src/components/link-segments.test.ts
create mode 100644 packages/session-ui/src/components/link-segments.ts
create mode 100644 packages/session-ui/src/components/linked-text.tsx
diff --git a/packages/session-ui/src/components/link-segments.test.ts b/packages/session-ui/src/components/link-segments.test.ts
new file mode 100644
index 00000000000..be584a9c101
--- /dev/null
+++ b/packages/session-ui/src/components/link-segments.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from "bun:test"
+import { linkSegments } from "./link-segments"
+
+describe("linkSegments", () => {
+ test("marks URLs in shell output", () => {
+ expect(linkSegments("Created https://github.com/anomalyco/opencode/pull/57302\n")).toEqual([
+ { text: "Created " },
+ {
+ text: "https://github.com/anomalyco/opencode/pull/57302",
+ href: "https://github.com/anomalyco/opencode/pull/57302",
+ },
+ { text: "\n" },
+ ])
+ })
+
+ test("leaves trailing punctuation outside the link", () => {
+ expect(linkSegments("(see https://example.com/a).")).toEqual([
+ { text: "(see " },
+ { text: "https://example.com/a", href: "https://example.com/a" },
+ { text: ")." },
+ ])
+ })
+
+ test("returns text without URLs as one run", () => {
+ expect(linkSegments("no links here")).toEqual([{ text: "no links here" }])
+ expect(linkSegments("")).toEqual([])
+ })
+})
diff --git a/packages/session-ui/src/components/link-segments.ts b/packages/session-ui/src/components/link-segments.ts
new file mode 100644
index 00000000000..5bd31aef7c2
--- /dev/null
+++ b/packages/session-ui/src/components/link-segments.ts
@@ -0,0 +1,20 @@
+/** Splits plain text into runs, marking each http(s) URL with its link target. */
+export function linkSegments(text: string) {
+ const result: { text: string; href?: string }[] = []
+ let last = 0
+
+ for (const match of text.matchAll(/https?:\/\/[^\s<>"'`]+/g)) {
+ // Sentence punctuation and closing brackets after a URL are almost never part of it.
+ const href = match[0].replace(/[),.;:!?\]]+$/, "")
+
+ if (!URL.canParse(href)) continue
+
+ if (match.index > last) result.push({ text: text.slice(last, match.index) })
+ result.push({ text: href, href })
+ last = match.index + href.length
+ }
+
+ if (last < text.length) result.push({ text: text.slice(last) })
+
+ return result
+}
diff --git a/packages/session-ui/src/components/linked-text.tsx b/packages/session-ui/src/components/linked-text.tsx
new file mode 100644
index 00000000000..dc8a46b4781
--- /dev/null
+++ b/packages/session-ui/src/components/linked-text.tsx
@@ -0,0 +1,21 @@
+import { Index, Show, createMemo } from "solid-js"
+import { linkSegments } from "./link-segments"
+
+/** Plain text with every http(s) URL rendered as a link that opens outside the app. */
+export function LinkedText(props: { text: string }) {
+ const segments = createMemo(() => linkSegments(props.text))
+
+ return (
+
+ {(segment) => (
+
+ {(href) => (
+
+ {segment().text}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css
index 788a7a87d4f..25ae7ed8954 100644
--- a/packages/session-ui/src/components/message-part.css
+++ b/packages/session-ui/src/components/message-part.css
@@ -433,6 +433,18 @@
}
}
+[data-slot="linked-text-link"] {
+ color: inherit;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in oklch, currentColor 40%, transparent);
+ text-underline-offset: 2px;
+
+ &:hover {
+ color: var(--v2-text-text-base);
+ text-decoration-color: currentColor;
+ }
+}
+
[data-component="bash-output"] {
direction: ltr;
unicode-bidi: isolate;
diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx
index fdb4d5a99bf..58fecf93fa6 100644
--- a/packages/session-ui/src/message/message-content.tsx
+++ b/packages/session-ui/src/message/message-content.tsx
@@ -15,6 +15,7 @@ import { useData } from "../context"
import { useDialog } from "@opencode/ui/context/dialog"
import { useI18n } from "@opencode/ui/context/i18n"
import { Markdown } from "../components/markdown"
+import { linkSegments } from "../components/link-segments"
import { ImagePreview } from "@opencode/ui/image-preview"
import { getFilename } from "@opencode/util/path"
import { AttachmentCard } from "./attachment-card"
@@ -534,21 +535,13 @@ function CurrentHighlightedText(props: {
const result: HighlightSegment[] = []
// Plain text between references may contain URLs, which become links.
- const pushText = (text: string) => {
- let last = 0
-
- for (const match of text.matchAll(urlPattern)) {
- const href = match[0].replace(/[),.;:!?\]]+$/, "")
-
- if (!URL.canParse(href)) continue
-
- if (match.index > last) result.push({ text: text.slice(last, match.index) })
- result.push({ text: href, type: "link" })
- last = match.index + href.length
- }
-
- if (last < text.length) result.push({ text: text.slice(last) })
- }
+ const pushText = (text: string) =>
+ result.push(
+ ...linkSegments(text).map((segment): HighlightSegment => ({
+ text: segment.text,
+ type: segment.href ? "link" : undefined,
+ })),
+ )
let last = 0
references.forEach((reference) => {
@@ -587,8 +580,6 @@ function CurrentHighlightedText(props: {
)
}
-const urlPattern = /https?:\/\/[^\s<>"'`]+/g
-
type HighlightSegment = { text: string; type?: "file" | "agent" | "link" }
/** A compaction the server admitted but has not started, drawn as the divider a started one opens with. */
diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx
index 9c9dbc123f7..b940486c24a 100644
--- a/packages/session-ui/src/tools/tool-renderer.tsx
+++ b/packages/session-ui/src/tools/tool-renderer.tsx
@@ -65,6 +65,7 @@ import {
} from "../message/current-tool-state"
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
import { followShellOutput } from "./shell-output"
+import { LinkedText } from "../components/linked-text"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
@@ -2075,7 +2076,9 @@ function ExecuteOutputRow(props: { output: string }) {
- {props.output}
+
+
+
@@ -2152,7 +2155,7 @@ function ExecuteCallRow(props: { call?: ExecuteToolCall; error?: string }) {
{(error) => (
- {error()}
+
)}
@@ -2207,7 +2210,13 @@ ToolRegistry.register({
return (
{command()}
- {(value) => {value()}}
+
+ {(value) => (
+
+
+
+ )}
+
)
}