mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): make links in shell and Code Mode output clickable (#57303)
This commit is contained in:
committed by
GitHub
Unverified
parent
675c0d9d78
commit
627bcc0f87
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user