diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca02..105c54e9d8ec 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -9,7 +9,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { useSettings, monoFontFamily } from "@/context/settings" +import { type AssistantCopyFormat, useSettings, monoFontFamily } from "@/context/settings" import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" @@ -126,6 +126,12 @@ export const SettingsGeneral: Component = () => { })), ) + const assistantCopyOptions = createMemo((): { value: AssistantCopyFormat; label: string }[] => [ + { value: "plain", label: language.t("settings.general.row.assistantCopyFormat.option.plain") }, + { value: "rich", label: language.t("settings.general.row.assistantCopyFormat.option.rich") }, + { value: "ask", label: language.t("settings.general.row.assistantCopyFormat.option.ask") }, + ]) + const fontOptions = [ { value: "ibm-plex-mono", label: "font.option.ibmPlexMono" }, { value: "cascadia-code", label: "font.option.cascadiaCode" }, @@ -338,6 +344,70 @@ export const SettingsGeneral: Component = () => { ) + const FeedSection = () => ( +
Hello world
") + expect(html).toContain(`Hello world
") + expect(html).not.toContain("var(--font-family-sans)") + }) + + test("inlines link and code styles", () => { + if (typeof DOMParser === "undefined") return + const html = serializeMarkdownClipboardHTML( + 'echo test',
+ )
+ expect(html).toContain("color: #0b66d2")
+ expect(html).toContain(`font-family: ${markdownClipboardMonoFont}`)
+ expect(html).toContain("background: #f6f8fa")
+ })
+
+ test("removes forbidden media tags before serializing", () => {
+ const html = serializeMarkdownClipboardHTML('Hello
')
+ expect(html).toContain("Hello
") + expect(html).not.toContain("hello
" }) + + expect(writes.length).toBe(1) + const item = writes[0]?.[0] as FakeClipboardItem + expect(item.data["text/plain"]).toBeInstanceOf(Blob) + expect(item.data["text/html"]).toBeInstanceOf(Blob) + expect(await item.data["text/plain"]?.text()).toBe("hello") + expect(await item.data["text/html"]?.text()).toBe("hello
") + + Object.defineProperty(globalThis, "navigator", { value: originalNavigator, configurable: true }) + Object.defineProperty(globalThis, "ClipboardItem", { value: originalClipboardItem, configurable: true }) + }) + + test("falls back to writeText when html is missing", async () => { + const originalNavigator = globalThis.navigator + const originalClipboardItem = globalThis.ClipboardItem + + const textWrites: string[] = [] + Object.defineProperty(globalThis, "navigator", { + value: { + clipboard: { + write: async () => {}, + writeText: async (value: string) => { + textWrites.push(value) + }, + }, + }, + configurable: true, + }) + Object.defineProperty(globalThis, "ClipboardItem", { value: undefined, configurable: true }) + + await writeClipboardPayload({ text: "plain" }) + + expect(textWrites).toEqual(["plain"]) + + Object.defineProperty(globalThis, "navigator", { value: originalNavigator, configurable: true }) + Object.defineProperty(globalThis, "ClipboardItem", { value: originalClipboardItem, configurable: true }) + }) + + test("serializes markdown html before writing clipboard payload", async () => { + const originalNavigator = globalThis.navigator + const originalClipboardItem = globalThis.ClipboardItem + + const writes: unknown[][] = [] + class FakeClipboardItem { + constructor(public data: Recordecho test" })
+
+ const item = writes[0]?.[0] as FakeClipboardItem
+ expect(await item.data["text/plain"]?.text()).toBe("hello")
+ expect(await item.data["text/html"]?.text()).toContain(`echo test")
+
+ Object.defineProperty(globalThis, "navigator", { value: originalNavigator, configurable: true })
+ Object.defineProperty(globalThis, "ClipboardItem", { value: originalClipboardItem, configurable: true })
+ })
+})
diff --git a/packages/ui/src/components/markdown-copy.ts b/packages/ui/src/components/markdown-copy.ts
new file mode 100644
index 000000000000..3adc869c72a7
--- /dev/null
+++ b/packages/ui/src/components/markdown-copy.ts
@@ -0,0 +1,109 @@
+import DOMPurify from "dompurify"
+
+export const markdownClipboardFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
+export const markdownClipboardMonoFont =
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
+
+export type MarkdownCopyMode = "plain" | "rich" | "ask"
+
+const config = {
+ USE_PROFILES: { html: true, mathMl: true },
+ SANITIZE_NAMED_PROPS: true,
+ FORBID_TAGS: ["style", "img", "video", "audio", "iframe", "script"],
+ FORBID_CONTENTS: ["style", "script"],
+}
+
+const banned = config.FORBID_TAGS.join(",")
+
+function strip(value: string) {
+ const html = value.trim()
+ if (!html) return html
+ if (typeof DOMParser === "undefined") {
+ return html
+ .replace(/