Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -338,6 +344,70 @@ export const SettingsGeneral: Component = () => {
</div>
)

const FeedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>

<SettingsList>
<Show when={platform.platform === "desktop"}>
<SettingsRow
title={language.t("settings.general.row.assistantCopyFormat.title")}
description={language.t("settings.general.row.assistantCopyFormat.description")}
>
<Select
data-action="settings-feed-assistant-copy-format"
options={assistantCopyOptions()}
current={assistantCopyOptions().find((option) => option.value === settings.general.assistantCopyFormat())}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && settings.general.setAssistantCopyFormat(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>
</Show>

<SettingsRow
title={language.t("settings.general.row.reasoningSummaries.title")}
description={language.t("settings.general.row.reasoningSummaries.description")}
>
<div data-action="settings-feed-reasoning-summaries">
<Switch
checked={settings.general.showReasoningSummaries()}
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
/>
</div>
</SettingsRow>

<SettingsRow
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
>
<div data-action="settings-feed-shell-tool-parts-expanded">
<Switch
checked={settings.general.shellToolPartsExpanded()}
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>

<SettingsRow
title={language.t("settings.general.row.editToolPartsExpanded.title")}
description={language.t("settings.general.row.editToolPartsExpanded.description")}
>
<div data-action="settings-feed-edit-tool-parts-expanded">
<Switch
checked={settings.general.editToolPartsExpanded()}
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)

const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
Expand Down Expand Up @@ -490,6 +560,8 @@ export const SettingsGeneral: Component = () => {
<div class="flex flex-col gap-8 w-full">
<GeneralSection />

<FeedSection />

<AppearanceSection />

<NotificationsSection />
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface SoundSettings {
errors: string
}

export type AssistantCopyFormat = "plain" | "rich" | "ask"

export interface Settings {
general: {
autoSave: boolean
Expand All @@ -26,6 +28,7 @@ export interface Settings {
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
assistantCopyFormat: AssistantCopyFormat
}
updates: {
startup: boolean
Expand All @@ -50,6 +53,7 @@ const defaultSettings: Settings = {
showReasoningSummaries: false,
shellToolPartsExpanded: true,
editToolPartsExpanded: false,
assistantCopyFormat: "rich",
},
updates: {
startup: true,
Expand Down Expand Up @@ -153,6 +157,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setEditToolPartsExpanded(value: boolean) {
setStore("general", "editToolPartsExpanded", value)
},
assistantCopyFormat: withFallback(
() => store.general?.assistantCopyFormat,
defaultSettings.general.assistantCopyFormat,
),
setAssistantCopyFormat(value: AssistantCopyFormat) {
setStore("general", "assistantCopyFormat", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ export const dict = {
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
"settings.general.section.feed": "Feed",
"settings.general.section.feed": "Feed & copy",
"settings.general.section.display": "Display",

"settings.general.row.language.title": "Language",
Expand All @@ -740,6 +740,12 @@ export const dict = {
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
"settings.general.row.editToolPartsExpanded.description":
"Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.assistantCopyFormat.title": "Message copy",
"settings.general.row.assistantCopyFormat.description":
"Sets the default format for the message Copy button.",
"settings.general.row.assistantCopyFormat.option.plain": "Plain text",
"settings.general.row.assistantCopyFormat.option.rich": "Rich text (default)",
"settings.general.row.assistantCopyFormat.option.ask": "Ask each time",

"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,10 +224,13 @@ export function MessageTimeline(props: {
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const assistantCopyMode = createMemo(() =>
platform.platform === "desktop" ? settings.general.assistantCopyFormat() : "plain",
)
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()

const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionID = createMemo(() => params.id)
Expand Down Expand Up @@ -1005,6 +1008,7 @@ export function MessageTimeline(props: {
active={active()}
status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
assistantCopyMode={assistantCopyMode()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
classes={{
Expand Down
134 changes: 134 additions & 0 deletions packages/ui/src/components/markdown-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, test } from "bun:test"
import {
markdownClipboardFont,
markdownClipboardMonoFont,
serializeMarkdownClipboardHTML,
writeClipboardPayload,
writeMarkdownClipboard,
} from "./markdown-copy"

describe("markdown clipboard html", () => {
test("wraps content with inline font stack", () => {
const html = serializeMarkdownClipboardHTML("<p>Hello <strong>world</strong></p>")
expect(html).toContain(`<div style="font-family: ${markdownClipboardFont};">`)
expect(html).toContain("<p>Hello <strong>world</strong></p>")
expect(html).not.toContain("var(--font-family-sans)")
})

test("inlines link and code styles", () => {
if (typeof DOMParser === "undefined") return
const html = serializeMarkdownClipboardHTML(
'<p><a href="https://opencode.ai">OpenCode</a></p><pre><code>echo test</code></pre>',
)
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('<p>Hello</p><img src="https://example.com/x.png"><video></video>')
expect(html).toContain("<p>Hello</p>")
expect(html).not.toContain("<img")
expect(html).not.toContain("<video")
})

test("returns empty string for blank html", () => {
expect(serializeMarkdownClipboardHTML(" ")).toBe("")
})
})

describe("markdown clipboard payload", () => {
test("writes both plain text and html mime types", async () => {
const originalNavigator = globalThis.navigator
const originalClipboardItem = globalThis.ClipboardItem

const writes: unknown[][] = []
class FakeClipboardItem {
constructor(public data: Record<string, Blob>) {}
}

Object.defineProperty(globalThis, "navigator", {
value: {
clipboard: {
write: async (items: unknown[]) => {
writes.push(items)
},
writeText: async () => {},
},
},
configurable: true,
})
Object.defineProperty(globalThis, "ClipboardItem", { value: FakeClipboardItem, configurable: true })

await writeClipboardPayload({ text: "hello", html: "<p>hello</p>" })

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("<p>hello</p>")

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: Record<string, Blob>) {}
}

Object.defineProperty(globalThis, "navigator", {
value: {
clipboard: {
write: async (items: unknown[]) => {
writes.push(items)
},
writeText: async () => {},
},
},
configurable: true,
})
Object.defineProperty(globalThis, "ClipboardItem", { value: FakeClipboardItem, configurable: true })

await writeMarkdownClipboard({ text: "hello", html: "<pre><code>echo test</code></pre>" })

const item = writes[0]?.[0] as FakeClipboardItem
expect(await item.data["text/plain"]?.text()).toBe("hello")
expect(await item.data["text/html"]?.text()).toContain(`<div style="font-family: ${markdownClipboardFont};">`)
expect(await item.data["text/html"]?.text()).toContain("<pre><code>echo test</code></pre>")

Object.defineProperty(globalThis, "navigator", { value: originalNavigator, configurable: true })
Object.defineProperty(globalThis, "ClipboardItem", { value: originalClipboardItem, configurable: true })
})
})
Loading
Loading