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) => ( + + + + )} + ) }