fix(app): make links in shell and Code Mode output clickable (#57303)

This commit is contained in:
Luke Parker
2026-10-20 15:45:03 +10:00
committed by GitHub
Unverified
parent 675c0d9d78
commit 627bcc0f87
6 changed files with 101 additions and 20 deletions
@@ -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([])
})
})
@@ -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
}
@@ -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 (
<Index each={segments()}>
{(segment) => (
<Show when={segment().href} fallback={segment().text}>
{(href) => (
<a data-slot="linked-text-link" href={href()} target="_blank" rel="noopener noreferrer">
{segment().text}
</a>
)}
</Show>
)}
</Index>
)
}
@@ -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;
@@ -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. */
@@ -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 }) {
<Show when={open()}>
<div data-slot="execute-tool-output">
<ConsoleOutput copy={props.output} variant="shell">
<span data-slot="bash-result">{props.output}</span>
<span data-slot="bash-result">
<LinkedText text={props.output} />
</span>
</ConsoleOutput>
</div>
</Show>
@@ -2152,7 +2155,7 @@ function ExecuteCallRow(props: { call?: ExecuteToolCall; error?: string }) {
<Show when={props.error}>
{(error) => (
<div data-slot="execute-tool-error" dir="ltr">
{error()}
<LinkedText text={error()} />
</div>
)}
</Show>
@@ -2207,7 +2210,13 @@ ToolRegistry.register({
return (
<ConsoleOutput copy={command()} variant="shell">
<span data-slot="bash-command">{command()}</span>
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
<Show when={output()}>
{(value) => (
<span data-slot="bash-result">
<LinkedText text={value()} />
</span>
)}
</Show>
</ConsoleOutput>
)
}